```
✨ feat(search): Ajoute EsySearch pour la recherche globale dans le CRM
Ajoute le service EsySearch, initialise l'index des admins et crée
une page de recherche unifiée. Active PWA en prod.
```
This commit is contained in:
2
.env
2
.env
@@ -90,4 +90,4 @@ MINIO_S3_CLIENT_ID=
|
|||||||
MINIO_S3_CLIENT_SECRET=
|
MINIO_S3_CLIENT_SECRET=
|
||||||
MINIO_S3_CLIENT_BUCKET=
|
MINIO_S3_CLIENT_BUCKET=
|
||||||
|
|
||||||
ESY_SEARCH_KEY=
|
ESY_SEARCH_KEY=b09d9a708b427d495c39fe6e8fc5361fe33fee57a0435f3e1bf3ed8155f2a277
|
||||||
|
|||||||
@@ -9,3 +9,17 @@ form {
|
|||||||
|
|
||||||
.animate-fadeIn { animation: fadeIn 0.3s ease-in-out; }
|
.animate-fadeIn { animation: fadeIn 0.3s ease-in-out; }
|
||||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
|
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
|
||||||
|
.custom-scrollbar::-webkit-scrollbar { width: 5px; height: 5px; }
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-track { background: transparent; }
|
||||||
|
.custom-scrollbar::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 10px; }
|
||||||
|
.dark .custom-scrollbar::-webkit-scrollbar-thumb { background: #334155; }
|
||||||
|
|
||||||
|
.page-transition { animation: fadeIn 0.3s ease-out; }
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from { opacity: 0; transform: translateY(4px); }
|
||||||
|
to { opacity: 1; transform: translateY(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Menu Accordion sans JS */
|
||||||
|
details summary::-webkit-details-marker { display:none; }
|
||||||
|
details[open] .arrow-icon { transform: rotate(180deg); }
|
||||||
|
|||||||
@@ -1 +1,2 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
"league/flysystem-bundle": "^3.6.1",
|
"league/flysystem-bundle": "^3.6.1",
|
||||||
"liip/imagine-bundle": "^2.15",
|
"liip/imagine-bundle": "^2.15",
|
||||||
"lufiipe/insee-sierene": ">=1",
|
"lufiipe/insee-sierene": ">=1",
|
||||||
|
"meilisearch/meilisearch-php": "^1.16",
|
||||||
"minishlink/web-push": "^9.0.4",
|
"minishlink/web-push": "^9.0.4",
|
||||||
"mittwald/vault-php": "^3.0.2",
|
"mittwald/vault-php": "^3.0.2",
|
||||||
"mobiledetect/mobiledetectlib": "^4.8.10",
|
"mobiledetect/mobiledetectlib": "^4.8.10",
|
||||||
|
|||||||
161
composer.lock
generated
161
composer.lock
generated
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "6cc6334636ac6b9f69ebd4f4736d69f4",
|
"content-hash": "f476989731711ededed94397298a39d6",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "async-aws/core",
|
"name": "async-aws/core",
|
||||||
@@ -5768,6 +5768,86 @@
|
|||||||
},
|
},
|
||||||
"time": "2022-12-02T22:17:43+00:00"
|
"time": "2022-12-02T22:17:43+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "meilisearch/meilisearch-php",
|
||||||
|
"version": "v1.16.1",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/meilisearch/meilisearch-php.git",
|
||||||
|
"reference": "f9f63e0e7d12ffaae54f7317fa8f4f4dfa8ae7b6"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/meilisearch/meilisearch-php/zipball/f9f63e0e7d12ffaae54f7317fa8f4f4dfa8ae7b6",
|
||||||
|
"reference": "f9f63e0e7d12ffaae54f7317fa8f4f4dfa8ae7b6",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"ext-json": "*",
|
||||||
|
"php": "^7.4 || ^8.0",
|
||||||
|
"php-http/discovery": "^1.7",
|
||||||
|
"psr/http-client": "^1.0",
|
||||||
|
"symfony/polyfill-php81": "^1.33"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"http-interop/http-factory-guzzle": "^1.2.0",
|
||||||
|
"php-cs-fixer/shim": "^3.59.3",
|
||||||
|
"phpstan/phpstan": "^2.0",
|
||||||
|
"phpstan/phpstan-deprecation-rules": "^2.0",
|
||||||
|
"phpstan/phpstan-phpunit": "^2.0",
|
||||||
|
"phpstan/phpstan-strict-rules": "^2.0",
|
||||||
|
"phpunit/phpunit": "^9.5 || ^10.5",
|
||||||
|
"symfony/http-client": "^5.4|^6.0|^7.0"
|
||||||
|
},
|
||||||
|
"suggest": {
|
||||||
|
"guzzlehttp/guzzle": "Use Guzzle ^7 as HTTP client",
|
||||||
|
"http-interop/http-factory-guzzle": "Factory for guzzlehttp/guzzle",
|
||||||
|
"symfony/http-client": "Use Symfony Http client"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"MeiliSearch\\": "src/",
|
||||||
|
"Meilisearch\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Clémentine Urquizar",
|
||||||
|
"email": "clementine@meilisearch.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Bruno Casali",
|
||||||
|
"email": "bruno@meilisearch.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Laurent Cazanove",
|
||||||
|
"email": "lau.cazanove@gmail.com"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Tomas Norkūnas",
|
||||||
|
"email": "norkunas.tom@gmail.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "PHP wrapper for the Meilisearch API",
|
||||||
|
"keywords": [
|
||||||
|
"api",
|
||||||
|
"client",
|
||||||
|
"instant",
|
||||||
|
"meilisearch",
|
||||||
|
"php",
|
||||||
|
"search"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/meilisearch/meilisearch-php/issues",
|
||||||
|
"source": "https://github.com/meilisearch/meilisearch-php/tree/v1.16.1"
|
||||||
|
},
|
||||||
|
"time": "2025-09-18T10:15:45+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "meyfa/php-svg",
|
"name": "meyfa/php-svg",
|
||||||
"version": "v0.9.1",
|
"version": "v0.9.1",
|
||||||
@@ -6720,6 +6800,85 @@
|
|||||||
],
|
],
|
||||||
"time": "2025-11-17T21:16:04+00:00"
|
"time": "2025-11-17T21:16:04+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "php-http/discovery",
|
||||||
|
"version": "1.20.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/php-http/discovery.git",
|
||||||
|
"reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/php-http/discovery/zipball/82fe4c73ef3363caed49ff8dd1539ba06044910d",
|
||||||
|
"reference": "82fe4c73ef3363caed49ff8dd1539ba06044910d",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"composer-plugin-api": "^1.0|^2.0",
|
||||||
|
"php": "^7.1 || ^8.0"
|
||||||
|
},
|
||||||
|
"conflict": {
|
||||||
|
"nyholm/psr7": "<1.0",
|
||||||
|
"zendframework/zend-diactoros": "*"
|
||||||
|
},
|
||||||
|
"provide": {
|
||||||
|
"php-http/async-client-implementation": "*",
|
||||||
|
"php-http/client-implementation": "*",
|
||||||
|
"psr/http-client-implementation": "*",
|
||||||
|
"psr/http-factory-implementation": "*",
|
||||||
|
"psr/http-message-implementation": "*"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"composer/composer": "^1.0.2|^2.0",
|
||||||
|
"graham-campbell/phpspec-skip-example-extension": "^5.0",
|
||||||
|
"php-http/httplug": "^1.0 || ^2.0",
|
||||||
|
"php-http/message-factory": "^1.0",
|
||||||
|
"phpspec/phpspec": "^5.1 || ^6.1 || ^7.3",
|
||||||
|
"sebastian/comparator": "^3.0.5 || ^4.0.8",
|
||||||
|
"symfony/phpunit-bridge": "^6.4.4 || ^7.0.1"
|
||||||
|
},
|
||||||
|
"type": "composer-plugin",
|
||||||
|
"extra": {
|
||||||
|
"class": "Http\\Discovery\\Composer\\Plugin",
|
||||||
|
"plugin-optional": true
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Http\\Discovery\\": "src/"
|
||||||
|
},
|
||||||
|
"exclude-from-classmap": [
|
||||||
|
"src/Composer/Plugin.php"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"authors": [
|
||||||
|
{
|
||||||
|
"name": "Márk Sági-Kazár",
|
||||||
|
"email": "mark.sagikazar@gmail.com"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"description": "Finds and installs PSR-7, PSR-17, PSR-18 and HTTPlug implementations",
|
||||||
|
"homepage": "http://php-http.org",
|
||||||
|
"keywords": [
|
||||||
|
"adapter",
|
||||||
|
"client",
|
||||||
|
"discovery",
|
||||||
|
"factory",
|
||||||
|
"http",
|
||||||
|
"message",
|
||||||
|
"psr17",
|
||||||
|
"psr7"
|
||||||
|
],
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/php-http/discovery/issues",
|
||||||
|
"source": "https://github.com/php-http/discovery/tree/1.20.0"
|
||||||
|
},
|
||||||
|
"time": "2024-10-02T11:20:13+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "phpdocumentor/reflection-common",
|
"name": "phpdocumentor/reflection-common",
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
|
|||||||
10
config/packages/http_discovery.yaml
Normal file
10
config/packages/http_discovery.yaml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
services:
|
||||||
|
Psr\Http\Message\RequestFactoryInterface: '@http_discovery.psr17_factory'
|
||||||
|
Psr\Http\Message\ResponseFactoryInterface: '@http_discovery.psr17_factory'
|
||||||
|
Psr\Http\Message\ServerRequestFactoryInterface: '@http_discovery.psr17_factory'
|
||||||
|
Psr\Http\Message\StreamFactoryInterface: '@http_discovery.psr17_factory'
|
||||||
|
Psr\Http\Message\UploadedFileFactoryInterface: '@http_discovery.psr17_factory'
|
||||||
|
Psr\Http\Message\UriFactoryInterface: '@http_discovery.psr17_factory'
|
||||||
|
|
||||||
|
http_discovery.psr17_factory:
|
||||||
|
class: Http\Discovery\Psr17Factory
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
pwa:
|
pwa:
|
||||||
|
|
||||||
image_processor: 'pwa.image_processor.gd'
|
image_processor: 'pwa.image_processor.gd'
|
||||||
favicons:
|
favicons:
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|||||||
49
src/Command/SearchCommand.php
Normal file
49
src/Command/SearchCommand.php
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Command;
|
||||||
|
|
||||||
|
use App\Entity\Account;
|
||||||
|
use App\Service\Search\Client;
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Component\Console\Attribute\AsCommand;
|
||||||
|
use Symfony\Component\Console\Command\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface;
|
||||||
|
|
||||||
|
#[AsCommand(name: 'app:search', description: 'Search')]
|
||||||
|
class SearchCommand extends Command
|
||||||
|
{
|
||||||
|
public function __construct(private readonly EntityManagerInterface $entityManager,private readonly Client $client,?string $name = null)
|
||||||
|
{
|
||||||
|
parent::__construct($name);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function execute(InputInterface $input, OutputInterface $output): int
|
||||||
|
{
|
||||||
|
$this->client->init();
|
||||||
|
|
||||||
|
$accounts = $this->entityManager->getRepository(Account::class)->findAll();
|
||||||
|
|
||||||
|
foreach ($accounts as $account) {
|
||||||
|
// ON IGNORE LES COMPTES AYANT LE RÔLE ROLE_ROOT
|
||||||
|
if (in_array('ROLE_ROOT', $account->getRoles(), true)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$datas = [
|
||||||
|
'id' => $account->getId(),
|
||||||
|
'name' => $account->getName(),
|
||||||
|
'surname' => $account->getFirstName(),
|
||||||
|
'email' => $account->getEmail(),
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->client->indexDocuments($datas, 'admin');
|
||||||
|
}
|
||||||
|
|
||||||
|
$output->writeln('Indexation terminée (hors ROOT).');
|
||||||
|
|
||||||
|
return Command::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
66
src/Controller/Dashboard/SearchController.php
Normal file
66
src/Controller/Dashboard/SearchController.php
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controller\Dashboard;
|
||||||
|
|
||||||
|
use App\Controller\EntityManagerInterface;
|
||||||
|
use App\Entity\Account;
|
||||||
|
use App\Entity\AccountResetPasswordRequest;
|
||||||
|
use App\Form\RequestPasswordConfirmType;
|
||||||
|
use App\Form\RequestPasswordRequestType;
|
||||||
|
use App\Repository\AccountRepository;
|
||||||
|
use App\Service\ResetPassword\Event\ResetPasswordConfirmEvent;
|
||||||
|
use App\Service\ResetPassword\Event\ResetPasswordEvent;
|
||||||
|
use App\Service\Search\Client;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\EventDispatcher\EventDispatcherInterface;
|
||||||
|
use Symfony\Component\HttpFoundation\JsonResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
use Symfony\Component\Security\Http\Authentication\AuthenticationUtils;
|
||||||
|
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||||
|
|
||||||
|
|
||||||
|
class SearchController extends AbstractController
|
||||||
|
{
|
||||||
|
#[Route(path: '/crm/recherche', name: 'app_crm_search', options: ['sitemap' => false], methods: ['GET'])]
|
||||||
|
public function crmSearch(
|
||||||
|
AccountRepository $accountRepository,
|
||||||
|
Client $client,
|
||||||
|
Request $request
|
||||||
|
): Response {
|
||||||
|
$query = $request->query->get('q', '');
|
||||||
|
$unifiedResults = [];
|
||||||
|
|
||||||
|
if (!empty($query)) {
|
||||||
|
$response = $client->searchGlobal($query, 20);
|
||||||
|
|
||||||
|
foreach ($response['results'] as $resultGroup) {
|
||||||
|
// On vérifie si l'index correspond aux administrateurs
|
||||||
|
if (str_contains($resultGroup['indexUid'], 'intranet_ludikevent_admin')) {
|
||||||
|
|
||||||
|
// Extraction des IDs pour éviter les requêtes en boucle
|
||||||
|
$ids = array_map(fn($h) => $h['id'], $resultGroup['hits']);
|
||||||
|
$accounts = $accountRepository->findBy(['id' => $ids]);
|
||||||
|
|
||||||
|
foreach ($accounts as $account) {
|
||||||
|
$unifiedResults[] = [
|
||||||
|
'title' => $account->getName() . " " . $account->getFirstName(),
|
||||||
|
'subtitle' => $account->getEmail(),
|
||||||
|
'link' => $this->generateUrl('app_crm_administrateur_view', ['id' => $account->getId()]),
|
||||||
|
'type' => 'Administrateur',
|
||||||
|
'id' => $account->getId(),
|
||||||
|
'initials' => strtoupper(substr($account->getName(), 0, 1) . substr($account->getFirstName(), 0, 1))
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->render('dashboard/search.twig', [
|
||||||
|
'results' => $unifiedResults,
|
||||||
|
'query' => $query
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
131
src/Service/Search/Client.php
Normal file
131
src/Service/Search/Client.php
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Service\Search;
|
||||||
|
|
||||||
|
use Meilisearch\Client as MeilisearchClient;
|
||||||
|
use Meilisearch\Contracts\SearchQuery;
|
||||||
|
|
||||||
|
class Client
|
||||||
|
{
|
||||||
|
private string $env;
|
||||||
|
private MeilisearchClient $client;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
// Récupération de l'environnement (ex: dev, prod)
|
||||||
|
$this->env = $_ENV['APP_ENV'] ?? 'dev';
|
||||||
|
|
||||||
|
// Connexion au serveur Meilisearch
|
||||||
|
$this->client = new MeilisearchClient(
|
||||||
|
"https://tools-meilisearch.esy-web.dev",
|
||||||
|
$_ENV['ESY_SEARCH_KEY']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retourne la configuration centrale des index et de leurs filtres.
|
||||||
|
*/
|
||||||
|
private function getIndexConfig(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
"intranet_ludikevent_admin" => [],
|
||||||
|
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialise les index avec préfixe et configure les attributs filtrables.
|
||||||
|
*/
|
||||||
|
public function init(): void
|
||||||
|
{
|
||||||
|
$config = $this->getIndexConfig();
|
||||||
|
|
||||||
|
foreach ($config as $indexName => $filterableAttributes) {
|
||||||
|
$fullName = $this->env . "_" . $indexName;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$this->client->createIndex($fullName, ['primaryKey' => 'id']);
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
// L'index existe déjà
|
||||||
|
}
|
||||||
|
|
||||||
|
// Met à jour les réglages de filtrage
|
||||||
|
$this->client->index($fullName)->updateFilterableAttributes($filterableAttributes);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recherche globale dans TOUS les index configurés (Multi-search).
|
||||||
|
* @param string $query Le terme recherché
|
||||||
|
* @param int $limitPerIndex Nombre max de résultats par catégorie
|
||||||
|
*/
|
||||||
|
public function searchGlobal(string $query, int $limitPerIndex = 5): array
|
||||||
|
{
|
||||||
|
$queries = [];
|
||||||
|
$indexNames = array_keys($this->getIndexConfig());
|
||||||
|
|
||||||
|
foreach ($indexNames as $indexName) {
|
||||||
|
$searchQuery =new SearchQuery();
|
||||||
|
$searchQuery->setQuery($query);
|
||||||
|
$searchQuery->setLimit($limitPerIndex);
|
||||||
|
$searchQuery->setIndexUid($this->env . "_" . $indexName);
|
||||||
|
$queries[] = $searchQuery;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Effectue une seule requête pour interroger tous les index
|
||||||
|
return $this->client->multiSearch($queries);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Recherche classique dans un index spécifique.
|
||||||
|
*/
|
||||||
|
public function search(string $key, string $query, array $options = []): array
|
||||||
|
{
|
||||||
|
$fullName = $this->env . "_" . $key;
|
||||||
|
return $this->client->index($fullName)->search($query, $options)->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ajoute ou met à jour des documents (Supporte un lot ou un document seul).
|
||||||
|
*/
|
||||||
|
public function indexDocuments(array $documents, string $key): void
|
||||||
|
{
|
||||||
|
if (empty($documents)) return;
|
||||||
|
|
||||||
|
$fullName = $this->env . "_intranet_ludikevent_" . $key;
|
||||||
|
|
||||||
|
// Si on envoie un seul document (non-multidimensionnel)
|
||||||
|
if (!isset($documents[0])) {
|
||||||
|
$documents = [$documents];
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->client->index($fullName)->addDocuments($documents);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Récupère un document précis.
|
||||||
|
*/
|
||||||
|
public function getDocument(string $key, $id): array
|
||||||
|
{
|
||||||
|
$fullName = $this->env . "_" . $key;
|
||||||
|
return $this->client->index($fullName)->getDocument($id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supprime un document par son ID.
|
||||||
|
*/
|
||||||
|
public function deleteDocument(string $key, $id): void
|
||||||
|
{
|
||||||
|
$fullName = $this->env . "_" . $key;
|
||||||
|
$this->client->index($fullName)->deleteDocument($id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Supprime un index complet.
|
||||||
|
*/
|
||||||
|
public function deleteIndex(string $key): void
|
||||||
|
{
|
||||||
|
$fullName = $this->env . "_" . $key;
|
||||||
|
$this->client->deleteIndex($fullName);
|
||||||
|
}
|
||||||
|
}
|
||||||
12
symfony.lock
12
symfony.lock
@@ -112,6 +112,18 @@
|
|||||||
"config/packages/nelmio_security.yaml"
|
"config/packages/nelmio_security.yaml"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"php-http/discovery": {
|
||||||
|
"version": "1.20",
|
||||||
|
"recipe": {
|
||||||
|
"repo": "github.com/symfony/recipes",
|
||||||
|
"branch": "main",
|
||||||
|
"version": "1.18",
|
||||||
|
"ref": "f45b5dd173a27873ab19f5e3180b2f661c21de02"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"config/packages/http_discovery.yaml"
|
||||||
|
]
|
||||||
|
},
|
||||||
"phpstan/phpstan": {
|
"phpstan/phpstan": {
|
||||||
"version": "2.1",
|
"version": "2.1",
|
||||||
"recipe": {
|
"recipe": {
|
||||||
|
|||||||
@@ -10,7 +10,9 @@
|
|||||||
<meta name="robots" content="noindex, nofollow">
|
<meta name="robots" content="noindex, nofollow">
|
||||||
{{ vite_asset('app.js', []) }}
|
{{ vite_asset('app.js', []) }}
|
||||||
|
|
||||||
{{ pwa() }}
|
{% if app.environment != 'dev' %}
|
||||||
|
{{ pwa(swAttributes={ 'nonce': csp_nonce('script') }) }}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
</head>
|
</head>
|
||||||
{# Le corps aura un fond gris clair pour correspondre au fond du logo #}
|
{# Le corps aura un fond gris clair pour correspondre au fond du logo #}
|
||||||
|
|||||||
@@ -5,27 +5,18 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>{% block title %}Administration{% endblock %} — Intranet Ludikevent</title>
|
<title>{% block title %}Administration{% endblock %} — Intranet Ludikevent</title>
|
||||||
{{ vite_asset('admin.js', {}) }}
|
{{ vite_asset('admin.js', {}) }}
|
||||||
{{ pwa(swAttributes={ 'nonce': csp_nonce('script') }) }}
|
{% if app.environment != 'dev' %}
|
||||||
<style>
|
{{ pwa() }}
|
||||||
.custom-scrollbar::-webkit-scrollbar { width: 5px; height: 5px; }
|
{% endif %}
|
||||||
.custom-scrollbar::-webkit-scrollbar-track { background: transparent; }
|
|
||||||
.custom-scrollbar::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 10px; }
|
|
||||||
.dark .custom-scrollbar::-webkit-scrollbar-thumb { background: #334155; }
|
|
||||||
|
|
||||||
.page-transition { animation: fadeIn 0.3s ease-out; }
|
|
||||||
@keyframes fadeIn {
|
|
||||||
from { opacity: 0; transform: translateY(4px); }
|
|
||||||
to { opacity: 1; transform: translateY(0); }
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body class="bg-slate-50 dark:bg-[#0f172a] text-slate-900 dark:text-slate-200 antialiased overflow-hidden font-sans">
|
<body class="bg-slate-50 dark:bg-[#0f172a] text-slate-900 dark:text-slate-200 antialiased overflow-hidden font-sans">
|
||||||
|
|
||||||
<div class="flex h-screen overflow-hidden">
|
<div class="flex h-screen overflow-hidden">
|
||||||
|
|
||||||
{# SIDEBAR MODERNE #}
|
{# SIDEBAR #}
|
||||||
<aside id="sidebar" class="fixed inset-y-0 left-0 z-40 w-72 bg-white dark:bg-[#1e293b] border-r border-slate-200 dark:border-slate-800 transform -translate-x-full lg:translate-x-0 transition-all duration-300 ease-in-out shadow-xl lg:shadow-none">
|
<aside id="sidebar" class="fixed inset-y-0 left-0 z-40 w-72 bg-white dark:bg-[#1e293b] border-r border-slate-200 dark:border-slate-800 lg:translate-x-0 transition-all duration-300 ease-in-out">
|
||||||
|
|
||||||
<div class="flex items-center px-8 h-20 border-b border-slate-100 dark:border-slate-800">
|
<div class="flex items-center px-8 h-20 border-b border-slate-100 dark:border-slate-800">
|
||||||
<div class="flex items-center space-x-3">
|
<div class="flex items-center space-x-3">
|
||||||
@@ -56,56 +47,63 @@
|
|||||||
|
|
||||||
<div>
|
<div>
|
||||||
<p class="px-4 mb-4 text-[10px] font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-[0.2em]">Configuration</p>
|
<p class="px-4 mb-4 text-[10px] font-semibold text-slate-400 dark:text-slate-500 uppercase tracking-[0.2em]">Configuration</p>
|
||||||
<div class="space-y-1">
|
<details class="group" {{ (app.request.get('_route') matches '/^app_crm_administrateur/' or app.request.get('_route') == 'app_crm_audit_logs') ? 'open' }}>
|
||||||
{% set isAdminActive = app.request.get('_route') matches '/^app_crm_administrateur/' %}
|
<summary class="list-none w-full flex items-center justify-between px-4 py-3 rounded-xl hover:bg-slate-100 dark:hover:bg-slate-800 text-slate-600 dark:text-slate-400 transition-all duration-200 cursor-pointer">
|
||||||
{% set isLogsActive = app.request.get('_route') == 'app_crm_audit_logs' %}
|
|
||||||
{% set isOpen = isAdminActive or isLogsActive %}
|
|
||||||
|
|
||||||
<button id="settings-toggle" class="w-full flex items-center justify-between px-4 py-3 rounded-xl hover:bg-slate-100 dark:hover:bg-slate-800 text-slate-600 dark:text-slate-400 transition-all duration-200 group">
|
|
||||||
<div class="flex items-center space-x-3">
|
<div class="flex items-center space-x-3">
|
||||||
<svg class="w-5 h-5 text-slate-400 group-hover:text-blue-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37a1.724 1.724 0 002.572-1.065z"></path></svg>
|
<svg class="w-5 h-5 text-slate-400 group-hover:text-blue-500" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37a1.724 1.724 0 002.572-1.065z"></path></svg>
|
||||||
<span class="font-semibold text-sm">Paramètres</span>
|
<span class="font-semibold text-sm">Paramètres</span>
|
||||||
</div>
|
</div>
|
||||||
<svg class="w-4 h-4 transition-transform duration-300 {{ isOpen ? 'rotate-180' }}" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path></svg>
|
<svg class="w-4 h-4 transition-transform duration-300 arrow-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path></svg>
|
||||||
</button>
|
</summary>
|
||||||
|
<div class="mt-2 space-y-1">
|
||||||
<div id="settings-submenu" class="mt-2 space-y-1 overflow-hidden transition-all duration-300 max-h-0 hidden">
|
<a href="{{ path('app_crm_administrateur') }}" class="block px-12 py-2 text-sm hover:text-blue-600 transition-colors">Gestion Admins</a>
|
||||||
<a href="{{ path('app_crm_administrateur') }}" class="block px-12 py-2 text-sm {{ isAdminActive ? 'text-blue-600 font-bold' : 'text-slate-500 hover:text-slate-900 dark:hover:text-white' }}">Gestion Admins</a>
|
<a href="{{ path('app_crm_audit_logs') }}" class="block px-12 py-2 text-sm hover:text-blue-600 transition-colors">Audit Logs</a>
|
||||||
<a href="{{ path('app_crm_audit_logs') }}" class="block px-12 py-2 text-sm {{ isLogsActive ? 'text-blue-600 font-bold' : 'text-slate-500 hover:text-slate-900 dark:hover:text-white' }}">Traçabilité (Logs)</a>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{# CONTENU FULL WIDTH #}
|
{# MAIN CONTENT #}
|
||||||
<main class="flex-1 flex flex-col min-w-0 lg:ml-72 bg-slate-50 dark:bg-[#0f172a] h-screen overflow-y-auto custom-scrollbar relative">
|
<main class="flex-1 flex flex-col min-w-0 lg:ml-72 bg-slate-50 dark:bg-[#0f172a] h-screen overflow-y-auto custom-scrollbar relative">
|
||||||
|
|
||||||
{# Header Glassmorphism #}
|
{# HEADER #}
|
||||||
<header class="h-20 flex items-center justify-between px-8 bg-white/80 dark:bg-[#1e293b]/80 backdrop-blur-md border-b border-slate-200 dark:border-slate-800 sticky top-0 z-30">
|
{# HEADER CORRIGÉ #}
|
||||||
<button id="sidebar-toggle" class="lg:hidden p-2 rounded-lg bg-slate-100 dark:bg-slate-800 text-slate-600">
|
<header class="h-20 flex items-center justify-between px-8 bg-white dark:bg-[#1e293b] border-b border-slate-200 dark:border-slate-800 sticky top-0 z-30 gap-6">
|
||||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16m-7 6h7"></path></svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div class="flex items-center space-x-6 ml-auto">
|
{# Barre de recherche parfaitement alignée #}
|
||||||
<div class="flex items-center space-x-3 px-4 py-2 bg-slate-100 dark:bg-slate-800 rounded-2xl border border-slate-200 dark:border-slate-700">
|
<div class="flex-1 max-w-2xl relative">
|
||||||
<div class="w-8 h-8 rounded-full bg-blue-100 dark:bg-blue-900/30 flex items-center justify-center text-blue-600 font-bold text-xs">
|
<form action="{{ path('app_crm_search') }}" method="GET" class="flex items-center">
|
||||||
{{ app.user.firstName|first|upper }}
|
|
||||||
</div>
|
{# Input avec padding droit suffisant pour le bouton #}
|
||||||
<div class="text-left hidden sm:block">
|
<input required type="text" name="q" placeholder="Recherche rapide..."
|
||||||
<p class="text-xs font-bold text-slate-800 dark:text-white">{{ app.user.firstName }} {{ app.user.name }}</p>
|
class="w-full pl-2 py-3 bg-slate-50 dark:bg-slate-900/50 border border-slate-200 dark:border-slate-700 rounded-2xl text-sm focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all outline-none text-slate-700 dark:text-slate-200">
|
||||||
<a href="{{ path('app_logout') }}" class="text-[10px] text-red-500 font-semibold hover:text-red-600 uppercase tracking-tighter">Déconnexion</a>
|
|
||||||
</div>
|
{# Bouton ancré à droite SANS chevauchement #}
|
||||||
|
<button type="submit" class="ml-2 px-5 py-2 bg-blue-600 text-white text-[10px] font-bold uppercase tracking-widest rounded-xl hover:bg-blue-700 transition-colors shadow-md">
|
||||||
|
Chercher
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Profil utilisateur #}
|
||||||
|
<div class="flex items-center space-x-3 px-4 py-2 bg-slate-50 dark:bg-slate-900/50 rounded-2xl border border-slate-200 dark:border-slate-700 shrink-0">
|
||||||
|
<div class="w-8 h-8 rounded-full bg-blue-600 flex items-center justify-center text-white font-bold text-xs">
|
||||||
|
{{ app.user.firstName|first|upper }}
|
||||||
|
</div>
|
||||||
|
<div class="text-left hidden sm:block">
|
||||||
|
<p class="text-xs font-bold text-slate-800 dark:text-white leading-none mb-1">{{ app.user.firstName }}</p>
|
||||||
|
<a href="{{ path('app_logout') }}" class="text-[9px] text-red-500 font-bold uppercase tracking-tighter hover:text-red-600">Déconnexion</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{# Zone de contenu 100% #}
|
{# CONTENT #}
|
||||||
<div class="p-6 md:p-8 lg:p-10 page-transition w-full">
|
<div class="p-6 md:p-10 page-transition w-full">
|
||||||
<div class="flex flex-col md:flex-row md:items-end justify-between mb-10 gap-4 border-b border-slate-200/60 dark:border-slate-800 pb-8">
|
<div class="flex items-end justify-between mb-10 pb-8 border-b border-slate-200 dark:border-slate-800/50">
|
||||||
<div>
|
<div>
|
||||||
<p class="text-blue-600 font-bold text-[10px] uppercase tracking-[0.4em] mb-2">Espace Administration</p>
|
<p class="text-blue-600 font-bold text-[10px] uppercase tracking-[0.4em] mb-2">Ludikevent Intranet</p>
|
||||||
<h1 class="text-4xl font-extrabold text-slate-900 dark:text-white tracking-tight">
|
<h1 class="text-4xl font-extrabold text-slate-900 dark:text-white">
|
||||||
{% block title_header %}{{ block('title') }}{% endblock %}
|
{% block title_header %}{{ block('title') }}{% endblock %}
|
||||||
</h1>
|
</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
64
templates/dashboard/search.twig
Normal file
64
templates/dashboard/search.twig
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
{% extends 'dashboard/base.twig' %}
|
||||||
|
|
||||||
|
{% block title %}Recherche : {{ query }}{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<div class="page-transition">
|
||||||
|
<div class="flex flex-col md:flex-row md:items-center justify-between mb-10 gap-6">
|
||||||
|
<div>
|
||||||
|
<p class="text-blue-600 font-bold text-[10px] uppercase tracking-[0.4em] mb-2">Recherche Multicritères</p>
|
||||||
|
<h1 class="text-3xl font-black text-slate-900 dark:text-white uppercase tracking-tighter">
|
||||||
|
Résultats pour <span class="text-blue-600">"{{ query }}"</span>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div class="bg-white dark:bg-slate-800 px-6 py-3 rounded-2xl border border-slate-200 dark:border-slate-700 shadow-sm">
|
||||||
|
<span class="text-xs font-bold text-slate-500 uppercase tracking-widest">
|
||||||
|
<span class="text-blue-600 text-lg mr-1">{{ results|length }}</span> correspondance(s)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Formulaire de mise à jour #}
|
||||||
|
<div class="mb-12">
|
||||||
|
<form action="{{ path('app_crm_search') }}" method="GET" class="relative max-w-2xl">
|
||||||
|
<input type="text" name="q" value="{{ query }}" placeholder="Rechercher à nouveau..."
|
||||||
|
class="w-full pl-6 pr-40 py-4 bg-white dark:bg-slate-900 border-2 border-slate-100 dark:border-slate-800 focus:border-blue-600 focus:ring-0 rounded-2xl text-slate-900 dark:text-white font-medium shadow-xl shadow-slate-200/40 dark:shadow-none transition-all outline-none">
|
||||||
|
<button type="submit" class="absolute right-2 top-2 bottom-2 px-6 bg-slate-900 dark:bg-blue-600 text-white text-[10px] font-bold uppercase tracking-widest rounded-xl hover:bg-blue-600 transition-all">
|
||||||
|
Actualiser
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if results is not empty %}
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
{% for item in results %}
|
||||||
|
<div class="bg-white dark:bg-[#1e293b] p-6 rounded-[2.5rem] border border-slate-200 dark:border-slate-800 shadow-[0_10px_40px_rgba(0,0,0,0.02)] hover:shadow-xl hover:translate-y-[-4px] transition-all group">
|
||||||
|
<div class="flex items-center space-x-4 mb-6">
|
||||||
|
<div class="w-14 h-14 bg-slate-100 dark:bg-slate-800 rounded-2xl flex items-center justify-center text-slate-900 dark:text-white font-black text-xl border border-slate-200 dark:border-slate-700 uppercase">
|
||||||
|
{{ item.initials }}
|
||||||
|
</div>
|
||||||
|
<div class="overflow-hidden">
|
||||||
|
<span class="px-2 py-0.5 bg-indigo-50 dark:bg-indigo-900/30 text-[8px] font-bold text-indigo-600 uppercase rounded-md border border-indigo-100 dark:border-indigo-800">
|
||||||
|
{{ item.type }}
|
||||||
|
</span>
|
||||||
|
<h3 class="font-bold text-slate-900 dark:text-white text-lg leading-none truncate mt-2">{{ item.title }}</h3>
|
||||||
|
<p class="text-xs text-slate-400 mt-1 truncate">{{ item.subtitle }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between pt-5 border-t border-slate-50 dark:border-slate-800">
|
||||||
|
<div class="text-[10px] font-mono text-slate-400">#{{ item.id }}</div>
|
||||||
|
<a href="{{ item.link }}" class="flex items-center space-x-2 px-4 py-2 bg-slate-900 dark:bg-slate-700 text-white rounded-xl text-[10px] font-bold uppercase tracking-widest hover:bg-blue-600 transition-colors">
|
||||||
|
<span>Accéder</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="p-20 bg-white dark:bg-slate-900 rounded-[3rem] text-center border-2 border-dashed border-slate-100 dark:border-slate-800">
|
||||||
|
<p class="text-slate-400 font-medium italic">Aucune donnée trouvée pour cette recherche.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user