feat: recherche entreprise SIRET/SIREN via API data.gouv.fr (proxy PHP)

Backend (ClientsController::entrepriseSearch) :
- Route GET /admin/clients/entreprise-search?q=...
- Proxy PHP vers https://recherche-entreprises.api.gouv.fr/search
  (pas d'appel API direct depuis le JS)
- Retourne JSON avec results[], total_results
- Gestion erreur avec 502 si API indisponible

Frontend (assets/modules/entreprise-search.js) :
- Module JS séparé, pas de script inline (CSP compatible)
- Modal glassmorphism avec champ recherche et liste résultats
- Chaque résultat affiche : nom, SIREN, SIRET, adresse, dirigeant, statut
- Au clic sur un résultat, auto-remplissage du formulaire :
  raisonSociale, siret, numTva (calcul clé TVA), address, zipCode, city
  + firstName/lastName du dirigeant si les champs sont vides
- Fermeture modal via overlay, bouton X, ou Escape

Template :
- Bouton "Rechercher SIRET / SIREN" à côté du bouton Retour
- Modal HTML avec header glass-dark, champ recherche, zone résultats scrollable

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Serreau Jovann
2026-04-04 10:56:10 +02:00
parent 53364b0068
commit a9a10e5584
4 changed files with 187 additions and 1 deletions

View File

@@ -1,4 +1,5 @@
import "./app.scss"
import { initEntrepriseSearch } from "./modules/entreprise-search.js"
// Membre / Super Admin : mutuellement exclusif
document.addEventListener('DOMContentLoaded', () => {
@@ -158,4 +159,7 @@ document.addEventListener('DOMContentLoaded', () => {
});
});
}
// Recherche entreprise (page creation client)
initEntrepriseSearch();
});

View File

@@ -0,0 +1,113 @@
/**
* Recherche entreprise via API data.gouv.fr
* Auto-remplissage du formulaire de création client
*/
const API_URL = '/admin/clients/entreprise-search'
const computeTva = (siren) => {
if (!siren) return ''
const key = (12 + 3 * (parseInt(siren, 10) % 97)) % 97
return 'FR' + String(key).padStart(2, '0') + siren
}
const capitalize = (s) => s ? s.charAt(0).toUpperCase() + s.slice(1).toLowerCase() : ''
const fillField = (id, value) => {
const el = document.getElementById(id)
if (el) el.value = value
}
const fillFieldIfEmpty = (id, value) => {
const el = document.getElementById(id)
if (el && !el.value && value) el.value = value
}
const renderResult = (e, onSelect) => {
const s = e.siege || {}
const d = (e.dirigeants && e.dirigeants[0]) || {}
const actif = e.etat_administratif === 'A'
const addr = [s.numero_voie, s.type_voie, s.libelle_voie].filter(Boolean).join(' ')
const div = document.createElement('div')
div.className = 'glass p-4 cursor-pointer hover:bg-white/80 transition-all'
div.innerHTML = `
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<div class="font-bold text-sm">${e.nom_complet || e.nom_raison_sociale || 'N/A'}</div>
<div class="text-xs text-gray-500 mt-1">
SIREN <span class="font-mono font-bold">${e.siren || '?'}</span>
${s.siret ? ' &mdash; SIRET <span class="font-mono font-bold">' + s.siret + '</span>' : ''}
</div>
<div class="text-xs text-gray-400 mt-1">${s.geo_adresse || s.adresse || ''}</div>
${d.nom ? '<div class="text-xs text-gray-400 mt-1">Dirigeant : ' + (d.prenoms || '') + ' ' + d.nom + '</div>' : ''}
</div>
<span class="shrink-0 px-2 py-1 text-[10px] font-bold uppercase ${actif ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}" style="border-radius: 6px;">
${actif ? 'Actif' : 'Ferme'}
</span>
</div>`
div.addEventListener('click', () => {
fillField('raisonSociale', e.nom_raison_sociale || e.nom_complet || '')
fillField('siret', s.siret || '')
fillField('numTva', computeTva(e.siren))
fillField('address', addr)
fillField('zipCode', s.code_postal || '')
fillField('city', s.libelle_commune || '')
const prenom = (d.prenoms || '').split(' ')[0]
fillFieldIfEmpty('firstName', capitalize(prenom))
fillFieldIfEmpty('lastName', capitalize(d.nom))
onSelect()
})
return div
}
export function initEntrepriseSearch() {
const modal = document.getElementById('modal-entreprise')
const overlay = document.getElementById('modal-overlay')
const closeBtn = document.getElementById('modal-close')
const openBtn = document.getElementById('btn-search-entreprise')
const input = document.getElementById('search-entreprise-input')
const searchBtn = document.getElementById('search-entreprise-btn')
const resultsEl = document.getElementById('search-entreprise-results')
const statusEl = document.getElementById('search-entreprise-status')
if (!modal || !openBtn) return
const openModal = () => { modal.classList.remove('hidden'); input.focus() }
const closeModal = () => modal.classList.add('hidden')
openBtn.addEventListener('click', openModal)
closeBtn.addEventListener('click', closeModal)
overlay.addEventListener('click', closeModal)
document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeModal() })
const doSearch = async () => {
const q = input.value.trim()
if (q.length < 2) return
resultsEl.innerHTML = ''
statusEl.textContent = 'Recherche en cours...'
statusEl.classList.remove('hidden')
try {
const resp = await fetch(API_URL + '?q=' + encodeURIComponent(q) + '&page=1&per_page=10')
const data = await resp.json()
if (!data.results || data.results.length === 0) {
statusEl.textContent = 'Aucun resultat trouve.'
return
}
statusEl.textContent = data.total_results + ' resultat(s) - cliquez pour selectionner'
data.results.forEach(e => resultsEl.appendChild(renderResult(e, closeModal)))
} catch (err) {
statusEl.textContent = 'Erreur : ' + err.message
}
}
searchBtn.addEventListener('click', doSearch)
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); doSearch() } })
}

View File

@@ -16,6 +16,7 @@ use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Contracts\HttpClient\HttpClientInterface;
#[Route('/admin/clients', name: 'app_admin_clients_')]
#[IsGranted('ROLE_EMPLOYE')]
@@ -147,6 +148,30 @@ class ClientsController extends AbstractController
return new JsonResponse($meilisearch->searchCustomers($query));
}
#[Route('/entreprise-search', name: 'entreprise_search', methods: ['GET'])]
public function entrepriseSearch(Request $request, HttpClientInterface $httpClient): JsonResponse
{
$query = trim($request->query->getString('q'));
if (\strlen($query) < 2) {
return new JsonResponse(['results' => [], 'total_results' => 0]);
}
try {
$response = $httpClient->request('GET', 'https://recherche-entreprises.api.gouv.fr/search', [
'query' => [
'q' => $query,
'page' => 1,
'per_page' => 10,
],
]);
return new JsonResponse($response->toArray());
} catch (\Throwable) {
return new JsonResponse(['results' => [], 'total_results' => 0, 'error' => 'Service indisponible'], 502);
}
}
#[Route('/{id}/toggle', name: 'toggle', methods: ['POST'])]
public function toggle(Customer $customer, EntityManagerInterface $em, MeilisearchService $meilisearch, LoggerInterface $logger): Response
{

View File

@@ -6,7 +6,16 @@
<div class="page-container">
<div class="flex items-center justify-between mb-8">
<h1 class="text-2xl font-bold heading-page">Nouveau client</h1>
<a href="{{ path('app_admin_clients_index') }}" class="px-4 py-2 glass font-bold uppercase text-xs tracking-widest hover:bg-gray-900 hover:text-white transition-all">Retour</a>
<div class="flex items-center gap-3">
<button type="button" id="btn-search-entreprise"
class="flex items-center gap-2 px-4 py-2 btn-gold text-xs font-bold uppercase tracking-wider text-gray-900">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
Rechercher SIRET / SIREN
</button>
<a href="{{ path('app_admin_clients_index') }}" class="px-4 py-2 glass font-bold uppercase text-xs tracking-widest hover:bg-gray-900 hover:text-white transition-all">Retour</a>
</div>
</div>
{% for type, messages in app.flashes %}
@@ -129,4 +138,39 @@
</div>
</form>
</div>
<!-- Modal recherche entreprise -->
<div id="modal-entreprise" class="hidden fixed inset-0 z-50 flex items-center justify-center">
<div class="absolute inset-0 bg-black/40 backdrop-blur-sm" id="modal-overlay"></div>
<div class="relative glass-heavy w-full max-w-2xl mx-4 max-h-[80vh] flex flex-col overflow-hidden" style="border-radius: 16px;">
<div class="glass-dark text-white px-6 py-4 flex items-center justify-between" style="border-radius: 16px 16px 0 0;">
<div class="flex items-center gap-3">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 text-[#fabf04]" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
<span class="text-sm font-bold uppercase tracking-widest">Recherche entreprise</span>
</div>
<button type="button" id="modal-close" class="text-gray-400 hover:text-white transition-colors">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div class="p-6">
<div class="flex gap-3 mb-4">
<input type="text" id="search-entreprise-input" placeholder="SIRET, SIREN, raison sociale..."
class="flex-1 px-4 py-3 input-glass text-sm font-medium" autocomplete="off">
<button type="button" id="search-entreprise-btn"
class="px-5 py-3 btn-gold text-xs font-bold uppercase tracking-wider text-gray-900">
Rechercher
</button>
</div>
<p id="search-entreprise-status" class="text-xs text-gray-400 mb-3 hidden"></p>
</div>
<div id="search-entreprise-results" class="overflow-y-auto px-6 pb-6 flex flex-col gap-3" style="max-height: 50vh;"></div>
</div>
</div>
{% endblock %}