feat: barre de recherche globale dans la navbar admin

Navbar admin :
- Barre de recherche persistante en haut de toutes les pages admin
- Recherche dans tous les index Meilisearch simultanément :
  clients (5), NDD (5), sites (5), contacts (5), revendeurs (3)
- Résultats en dropdown glassmorphism avec icône par type
- Clic sur un résultat → page + tab correspondant :
  Client → /admin/clients/{id}
  NDD → /admin/clients/{id}?tab=ndd
  Site → /admin/clients/{id}?tab=sites
  Contact → /admin/clients/{id}?tab=contacts
  Revendeur → /admin/revendeurs/{id}/edit

DashboardController::globalSearch :
- Route GET /admin/global-search?q=...
- Agrège les résultats de 5 index Meilisearch
- Retourne [{type, label, sub, url}]

app.js :
- Debounce 250ms, min 2 chars
- Badges type (Client, NDD, Site, Contact, Revendeur)
- Fermeture Escape / clic extérieur

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Serreau Jovann
2026-04-04 21:29:36 +02:00
parent f68712bd02
commit c849a31ea1
3 changed files with 111 additions and 4 deletions

View File

@@ -213,4 +213,64 @@ document.addEventListener('DOMContentLoaded', () => {
// Recherche entreprise (page creation client)
initEntrepriseSearch();
// Recherche globale (navbar admin)
const globalInput = document.getElementById('global-search');
const globalResults = document.getElementById('global-search-results');
if (globalInput && globalResults) {
const typeIcons = {
client: '&#128100;',
ndd: '&#127760;',
site: '&#128187;',
contact: '&#128101;',
revendeur: '&#127970;',
};
const typeLabels = {
client: 'Client',
ndd: 'NDD',
site: 'Site',
contact: 'Contact',
revendeur: 'Revendeur',
};
let globalDebounce;
globalInput.addEventListener('input', () => {
clearTimeout(globalDebounce);
const q = globalInput.value.trim();
if (q.length < 2) {
globalResults.classList.add('hidden');
globalResults.innerHTML = '';
return;
}
globalDebounce = setTimeout(async () => {
const resp = await fetch('/admin/global-search?q=' + encodeURIComponent(q));
const hits = await resp.json();
if (hits.length === 0) {
globalResults.innerHTML = '<div class="px-4 py-3 text-xs text-gray-400">Aucun resultat.</div>';
} else {
globalResults.innerHTML = hits.map(h =>
`<a href="${h.url}" class="flex items-center gap-3 px-4 py-2 hover:bg-white/80 border-b border-white/10 transition-colors">
<span class="text-sm">${typeIcons[h.type] || ''}</span>
<div class="min-w-0 flex-1">
<span class="text-xs font-bold block truncate">${h.label}</span>
${h.sub ? `<span class="text-[10px] text-gray-400 block truncate">${h.sub}</span>` : ''}
</div>
<span class="px-1.5 py-0.5 bg-gray-100 text-gray-500 font-bold uppercase text-[8px] rounded shrink-0">${typeLabels[h.type] || h.type}</span>
</a>`
).join('');
}
globalResults.classList.remove('hidden');
}, 250);
});
document.addEventListener('click', (e) => {
/* istanbul ignore next */ if (!globalResults.contains(e.target) && e.target !== globalInput) {
globalResults.classList.add('hidden');
}
});
globalInput.addEventListener('keydown', (e) => {
if (e.key === 'Escape') globalResults.classList.add('hidden');
});
}
});

View File

@@ -2,7 +2,10 @@
namespace App\Controller\Admin;
use App\Service\MeilisearchService;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
@@ -20,4 +23,38 @@ class DashboardController extends AbstractController
],
]);
}
#[Route('/global-search', name: 'global_search', methods: ['GET'])]
public function globalSearch(Request $request, MeilisearchService $meilisearch): JsonResponse
{
$q = trim($request->query->getString('q'));
if (\strlen($q) < 2) {
return new JsonResponse([]);
}
$results = [];
foreach ($meilisearch->searchCustomers($q, 5) as $h) {
$results[] = ['type' => 'client', 'label' => $h['fullName'] ?? $h['raisonSociale'] ?? '', 'sub' => $h['email'] ?? '', 'url' => '/admin/clients/'.$h['id']];
}
foreach ($meilisearch->searchDomains($q, 5) as $h) {
$results[] = ['type' => 'ndd', 'label' => $h['fqdn'] ?? '', 'sub' => $h['customerName'] ?? '', 'url' => '/admin/clients/'.($h['customerId'] ?? 0).'?tab=ndd'];
}
foreach ($meilisearch->searchWebsites($q, 5) as $h) {
$results[] = ['type' => 'site', 'label' => $h['name'] ?? '', 'sub' => $h['customerName'] ?? '', 'url' => '/admin/clients/'.($h['customerId'] ?? 0).'?tab=sites'];
}
foreach ($meilisearch->searchContacts($q, 5) as $h) {
$results[] = ['type' => 'contact', 'label' => $h['fullName'] ?? '', 'sub' => ($h['role'] ?? '').($h['email'] ? ' — '.$h['email'] : ''), 'url' => '/admin/clients/'.($h['customerId'] ?? 0).'?tab=contacts'];
}
foreach ($meilisearch->searchRevendeurs($q, 3) as $h) {
$results[] = ['type' => 'revendeur', 'label' => $h['fullName'] ?? $h['raisonSociale'] ?? '', 'sub' => $h['codeRevendeur'] ?? '', 'url' => '/admin/revendeurs/'.($h['id'] ?? 0).'/edit'];
}
return new JsonResponse($results);
}
}

View File

@@ -120,12 +120,22 @@
<div class="admin-overlay" id="admin-overlay"></div>
<div class="admin-content glass-bg flex flex-col">
<div class="lg:hidden sticky top-0 z-30 glass-dark-heavy px-4 py-3 flex items-center justify-between" style="border-radius: 0;">
<button id="admin-sidebar-toggle" class="text-white/60 hover:text-white" aria-label="Menu">
<div class="sticky top-0 z-30 glass-heavy px-4 py-3 flex items-center justify-between gap-4" style="border-radius: 0; border-bottom: 1px solid rgba(255,255,255,0.2);">
<button id="admin-sidebar-toggle" class="lg:hidden text-gray-500 hover:text-gray-900" aria-label="Menu">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"/></svg>
</button>
<span class="text-white/80 font-bold uppercase text-xs tracking-widest">Admin</span>
<span class="w-5"></span>
<div class="flex-1 max-w-xl relative">
<input type="text" id="global-search" placeholder="Recherche rapide (client, domaine, site, contact...)"
class="w-full px-4 py-2 pl-9 input-glass text-xs font-medium" autocomplete="off">
<svg class="w-3.5 h-3.5 absolute left-3 top-2.5 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
<div id="global-search-results" class="hidden absolute left-0 right-0 top-full mt-1 glass-heavy border border-white/30 max-h-80 overflow-y-auto z-50" style="border-radius: 12px;">
</div>
</div>
<div class="flex items-center gap-3">
<span class="hidden md:block text-[10px] font-bold uppercase tracking-wider text-gray-400">{{ app.user.fullName }}</span>
</div>
</div>
{% block admin_content %}{% endblock %}