d064f8d28e
- Étape 6 : formulaire de saisie dynamique des relevés (piloté par source_type_fields, calendriers grégorien/julien/républicain) - Étape 7 : workflow de statut des sources + notifications mail+DB (SourceAValider, SourceRejetee) - Étape 8 : recherche fulltext PostgreSQL avec filtres type/lieu/années et CTE récursive pour les subdivisions de lieux - Étape 9 : export GEDCOM 5.5.1 (GedcomExportService + DateConversionService) - Types de lieux : CRUD admin (LieuTypeController) avec champ ordre - Composant lieu-picker : modale Alpine.js avec recherche AJAX + debounce - Filtres sources : statut, type, lieu (CTE récursive), période annee_debut/annee_fin - Filtres lieux : type, texte, lieu parent avec descendants (CTE récursive) - Migration : lieu_id + annee_debut + annee_fin sur sources Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
199 lines
7.6 KiB
PHP
199 lines
7.6 KiB
PHP
{{--
|
|
Composant de sélection d'un lieu par recherche contextuelle.
|
|
|
|
Paramètres :
|
|
$name : nom du champ hidden (ex: "lieu_id")
|
|
$label : libellé affiché au-dessus du champ
|
|
$value : id du lieu sélectionné (null si aucun)
|
|
$displayValue : texte affiché (nom_long du lieu sélectionné)
|
|
$required : bool — rend le champ obligatoire
|
|
$placeholder : texte quand rien n'est sélectionné
|
|
--}}
|
|
@props([
|
|
'name',
|
|
'label' => 'Lieu',
|
|
'value' => null,
|
|
'displayValue' => '',
|
|
'required' => false,
|
|
'placeholder' => 'Rechercher un lieu…',
|
|
])
|
|
|
|
<div
|
|
x-data="{
|
|
open: false,
|
|
search: '',
|
|
results: [],
|
|
loading: false,
|
|
selected: {
|
|
id: {{ $value ? (int)$value : 'null' }},
|
|
name: {{ json_encode($displayValue ?: '') }}
|
|
},
|
|
debounceTimer: null,
|
|
|
|
openModal() {
|
|
this.open = true;
|
|
this.search = '';
|
|
this.results = [];
|
|
this.$nextTick(() => this.$refs.searchInput?.focus());
|
|
},
|
|
|
|
onSearchInput() {
|
|
clearTimeout(this.debounceTimer);
|
|
this.debounceTimer = setTimeout(() => this.fetchResults(), 220);
|
|
},
|
|
|
|
async fetchResults() {
|
|
if (this.search.length < 1) { this.results = []; return; }
|
|
this.loading = true;
|
|
try {
|
|
const res = await fetch(
|
|
'{{ route('lieux.search') }}?q=' + encodeURIComponent(this.search),
|
|
{ headers: { 'X-Requested-With': 'XMLHttpRequest' } }
|
|
);
|
|
this.results = await res.json();
|
|
} finally {
|
|
this.loading = false;
|
|
}
|
|
},
|
|
|
|
select(lieu) {
|
|
this.selected = { id: lieu.id, name: lieu.nom_long };
|
|
this.open = false;
|
|
this.search = '';
|
|
this.results = [];
|
|
},
|
|
|
|
clear() {
|
|
this.selected = { id: null, name: '' };
|
|
}
|
|
}"
|
|
@keydown.escape.window="open = false"
|
|
>
|
|
<label class="block text-sm font-medium text-gray-700 mb-1">
|
|
{{ $label }}
|
|
@if($required) <span class="text-red-500">*</span> @endif
|
|
</label>
|
|
|
|
{{-- Champ hidden pour la valeur soumise --}}
|
|
<input type="hidden" name="{{ $name }}" :value="selected.id ?? ''">
|
|
|
|
{{-- Affichage du lieu sélectionné + boutons --}}
|
|
<div class="flex gap-2">
|
|
<button
|
|
type="button"
|
|
@click="openModal()"
|
|
class="flex-1 text-left px-3 py-2 border border-gray-300 rounded-md bg-white text-sm shadow-sm
|
|
hover:border-indigo-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 transition-colors
|
|
@error($name) border-red-500 @enderror"
|
|
>
|
|
<span x-show="selected.id" class="text-gray-900" x-text="selected.name"></span>
|
|
<span x-show="!selected.id" class="text-gray-400">{{ $placeholder }}</span>
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
x-show="selected.id"
|
|
@click="clear()"
|
|
title="Effacer"
|
|
class="px-2 py-2 text-gray-400 hover:text-red-500 transition-colors"
|
|
>
|
|
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
@error($name)
|
|
<p class="mt-1 text-sm text-red-600">{{ $message }}</p>
|
|
@enderror
|
|
|
|
{{-- Modale de recherche --}}
|
|
<div
|
|
x-show="open"
|
|
x-cloak
|
|
class="fixed inset-0 z-50 flex items-start justify-center pt-24 px-4"
|
|
@click.self="open = false"
|
|
>
|
|
{{-- Fond semi-transparent --}}
|
|
<div class="absolute inset-0 bg-black/40" @click="open = false"></div>
|
|
|
|
{{-- Panneau --}}
|
|
<div class="relative bg-white rounded-xl shadow-2xl w-full max-w-lg z-10 overflow-hidden">
|
|
{{-- En-tête --}}
|
|
<div class="px-4 py-3 border-b border-gray-100 flex items-center gap-3">
|
|
<svg class="w-5 h-5 text-gray-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
|
d="M21 21l-4.35-4.35M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0z"/>
|
|
</svg>
|
|
<input
|
|
x-ref="searchInput"
|
|
type="text"
|
|
x-model="search"
|
|
@input="onSearchInput()"
|
|
placeholder="Nom, code INSEE…"
|
|
class="flex-1 text-sm outline-none placeholder-gray-400"
|
|
>
|
|
<button type="button" @click="open = false"
|
|
class="text-gray-400 hover:text-gray-600">
|
|
<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="M6 18L18 6M6 6l12 12"/>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
{{-- Résultats --}}
|
|
<div class="max-h-80 overflow-y-auto">
|
|
{{-- Chargement --}}
|
|
<div x-show="loading" class="px-4 py-6 text-center text-sm text-gray-400">
|
|
Recherche…
|
|
</div>
|
|
|
|
{{-- Aucun résultat --}}
|
|
<div x-show="!loading && search.length > 0 && results.length === 0"
|
|
class="px-4 py-6 text-center text-sm text-gray-400">
|
|
Aucun lieu trouvé pour « <span x-text="search"></span> »
|
|
</div>
|
|
|
|
{{-- Invite initiale --}}
|
|
<div x-show="!loading && search.length === 0"
|
|
class="px-4 py-6 text-center text-sm text-gray-400">
|
|
Saisissez au moins une lettre pour rechercher
|
|
</div>
|
|
|
|
{{-- Liste --}}
|
|
<ul x-show="results.length > 0">
|
|
<template x-for="lieu in results" :key="lieu.id">
|
|
<li>
|
|
<button
|
|
type="button"
|
|
@click="select(lieu)"
|
|
class="w-full text-left px-4 py-3 hover:bg-indigo-50 flex items-center justify-between gap-3 transition-colors"
|
|
>
|
|
<div>
|
|
<span class="text-sm font-medium text-gray-900" x-text="lieu.nom_long"></span>
|
|
<span x-show="lieu.code"
|
|
class="ml-2 text-xs text-gray-400"
|
|
x-text="lieu.code"></span>
|
|
</div>
|
|
<span x-show="lieu.type"
|
|
class="shrink-0 text-xs px-2 py-0.5 bg-gray-100 text-gray-500 rounded-full"
|
|
x-text="lieu.type"></span>
|
|
</button>
|
|
</li>
|
|
</template>
|
|
</ul>
|
|
</div>
|
|
|
|
@can('create', App\Models\Lieu::class)
|
|
{{-- Pied : créer un nouveau lieu --}}
|
|
<div class="border-t border-gray-100 px-4 py-2.5">
|
|
<a href="{{ route('lieux.create') }}" target="_blank"
|
|
class="text-xs text-indigo-600 hover:underline">
|
|
+ Créer un nouveau lieu
|
|
</a>
|
|
</div>
|
|
@endcan
|
|
</div>
|
|
</div>
|
|
</div>
|