feat: Remplacer le champ UUID par une recherche autocomplete pour la liaison parent-élève
L'ajout d'un parent à un élève nécessitait de connaître et coller manuellement l'UUID du compte parent, ce qui était source d'erreurs et très peu ergonomique pour les administrateurs. Le nouveau composant ParentSearchInput offre une recherche par nom/email avec autocomplétion (debounce 300ms, navigation clavier, ARIA combobox). Les parents déjà liés sont exclus des résultats, et la sélection se réinitialise proprement quand l'admin retape dans le champ.
This commit is contained in:
@@ -0,0 +1,336 @@
|
||||
<script lang="ts">
|
||||
import { getApiBaseUrl } from '$lib/api/config';
|
||||
import { authenticatedFetch } from '$lib/auth';
|
||||
|
||||
interface ParentResult {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
let {
|
||||
onSelect,
|
||||
onClear,
|
||||
excludeIds = [],
|
||||
placeholder = 'Rechercher un parent par nom ou email...'
|
||||
}: {
|
||||
onSelect: (parent: ParentResult) => void;
|
||||
onClear?: () => void;
|
||||
excludeIds?: string[];
|
||||
placeholder?: string;
|
||||
} = $props();
|
||||
|
||||
let query = $state('');
|
||||
let results = $state<ParentResult[]>([]);
|
||||
let isOpen = $state(false);
|
||||
let isLoading = $state(false);
|
||||
let activeIndex = $state(-1);
|
||||
let selectedLabel = $state('');
|
||||
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
let abortController: AbortController | null = null;
|
||||
let containerEl: HTMLDivElement | undefined = $state();
|
||||
let inputEl: HTMLInputElement | undefined = $state();
|
||||
|
||||
const listboxId = 'parent-search-listbox';
|
||||
|
||||
$effect(() => {
|
||||
return () => {
|
||||
if (timeoutId !== null) clearTimeout(timeoutId);
|
||||
abortController?.abort();
|
||||
};
|
||||
});
|
||||
|
||||
async function searchParents(searchQuery: string) {
|
||||
if (searchQuery.length < 2) {
|
||||
results = [];
|
||||
isOpen = false;
|
||||
return;
|
||||
}
|
||||
|
||||
abortController?.abort();
|
||||
abortController = new AbortController();
|
||||
|
||||
try {
|
||||
isLoading = true;
|
||||
const apiUrl = getApiBaseUrl();
|
||||
const params = new URLSearchParams({
|
||||
role: 'ROLE_PARENT',
|
||||
search: searchQuery,
|
||||
itemsPerPage: '10'
|
||||
});
|
||||
|
||||
const response = await authenticatedFetch(
|
||||
`${apiUrl}/users?${params.toString()}`,
|
||||
{ signal: abortController.signal }
|
||||
);
|
||||
|
||||
if (!response.ok) return;
|
||||
|
||||
const data = await response.json();
|
||||
const members = data['hydra:member'] ?? data['member'] ?? [];
|
||||
const mapped = members.map((u: Record<string, string>) => ({
|
||||
id: u['id'] ?? '',
|
||||
firstName: u['firstName'] ?? '',
|
||||
lastName: u['lastName'] ?? '',
|
||||
email: u['email'] ?? ''
|
||||
}));
|
||||
results = excludeIds.length > 0
|
||||
? mapped.filter((p: ParentResult) => !excludeIds.includes(p.id))
|
||||
: mapped;
|
||||
isOpen = true;
|
||||
activeIndex = -1;
|
||||
} catch (e) {
|
||||
if (e instanceof DOMException && e.name === 'AbortError') return;
|
||||
results = [];
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleInput(event: Event) {
|
||||
const target = event.target as HTMLInputElement;
|
||||
query = target.value;
|
||||
if (selectedLabel !== '') {
|
||||
selectedLabel = '';
|
||||
onClear?.();
|
||||
}
|
||||
|
||||
if (timeoutId !== null) clearTimeout(timeoutId);
|
||||
|
||||
timeoutId = globalThis.setTimeout(() => {
|
||||
searchParents(query);
|
||||
}, 300);
|
||||
}
|
||||
|
||||
function formatParentLabel(parent: ParentResult): string {
|
||||
const name = `${parent.firstName} ${parent.lastName}`.trim();
|
||||
return name || parent.email;
|
||||
}
|
||||
|
||||
function selectParent(parent: ParentResult) {
|
||||
selectedLabel = formatParentLabel(parent);
|
||||
query = selectedLabel;
|
||||
isOpen = false;
|
||||
results = [];
|
||||
activeIndex = -1;
|
||||
onSelect(parent);
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (!isOpen || results.length === 0) {
|
||||
if (event.key === 'Escape') {
|
||||
isOpen = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
event.preventDefault();
|
||||
activeIndex = (activeIndex + 1) % results.length;
|
||||
break;
|
||||
case 'ArrowUp':
|
||||
event.preventDefault();
|
||||
activeIndex = activeIndex <= 0 ? results.length - 1 : activeIndex - 1;
|
||||
break;
|
||||
case 'Enter': {
|
||||
event.preventDefault();
|
||||
const selected = results[activeIndex];
|
||||
if (activeIndex >= 0 && selected) {
|
||||
selectParent(selected);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'Escape':
|
||||
isOpen = false;
|
||||
activeIndex = -1;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
const target = event.target;
|
||||
if (containerEl && target instanceof HTMLElement && !containerEl.contains(target)) {
|
||||
isOpen = false;
|
||||
activeIndex = -1;
|
||||
}
|
||||
}
|
||||
|
||||
function optionId(index: number): string {
|
||||
return `${listboxId}-option-${index}`;
|
||||
}
|
||||
|
||||
export function clear() {
|
||||
query = '';
|
||||
selectedLabel = '';
|
||||
results = [];
|
||||
isOpen = false;
|
||||
activeIndex = -1;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:document onclick={handleClickOutside} />
|
||||
|
||||
<div class="parent-search" bind:this={containerEl}>
|
||||
<label for="parent-search-input" class="sr-only">{placeholder}</label>
|
||||
<div class="input-wrapper">
|
||||
<svg class="search-icon" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<input
|
||||
bind:this={inputEl}
|
||||
id="parent-search-input"
|
||||
type="text"
|
||||
role="combobox"
|
||||
autocomplete="off"
|
||||
aria-expanded={isOpen}
|
||||
aria-controls={listboxId}
|
||||
aria-activedescendant={activeIndex >= 0 ? optionId(activeIndex) : undefined}
|
||||
aria-label={placeholder}
|
||||
{placeholder}
|
||||
value={query}
|
||||
oninput={handleInput}
|
||||
onkeydown={handleKeydown}
|
||||
/>
|
||||
{#if isLoading}
|
||||
<span class="loading-indicator" aria-hidden="true">...</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if isOpen}
|
||||
<ul id={listboxId} role="listbox" class="dropdown" aria-label="Résultats de recherche parents">
|
||||
{#if results.length === 0}
|
||||
<li class="no-results" role="presentation">Aucun parent trouvé</li>
|
||||
{:else}
|
||||
{#each results as parent, index (parent.id)}
|
||||
<li
|
||||
id={optionId(index)}
|
||||
role="option"
|
||||
aria-selected={index === activeIndex}
|
||||
class="option"
|
||||
class:active={index === activeIndex}
|
||||
onclick={() => selectParent(parent)}
|
||||
onmouseenter={() => { activeIndex = index; }}
|
||||
>
|
||||
<span class="option-name">{formatParentLabel(parent)}</span>
|
||||
{#if parent.firstName || parent.lastName}
|
||||
<span class="option-email">{parent.email}</span>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
{/if}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.parent-search {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: 0.75rem;
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
color: #9ca3af;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 0.625rem 2.5rem 0.625rem 2.25rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
color: #374151;
|
||||
background: white;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
input::placeholder {
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.loading-indicator {
|
||||
position: absolute;
|
||||
right: 0.75rem;
|
||||
color: #9ca3af;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
margin-top: 0.25rem;
|
||||
background: white;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.375rem;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
max-height: 15rem;
|
||||
overflow-y: auto;
|
||||
z-index: 10;
|
||||
list-style: none;
|
||||
padding: 0.25rem 0;
|
||||
margin: 0.25rem 0 0;
|
||||
}
|
||||
|
||||
.option {
|
||||
padding: 0.5rem 0.75rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.option:hover,
|
||||
.option.active {
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.option-name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.option-email {
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.no-results {
|
||||
padding: 0.75rem;
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border-width: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { getApiBaseUrl } from '$lib/api/config';
|
||||
import { authenticatedFetch } from '$lib/auth';
|
||||
import ParentSearchInput from '$lib/components/molecules/ParentSearchInput/ParentSearchInput.svelte';
|
||||
|
||||
interface Guardian {
|
||||
id: string;
|
||||
@@ -37,8 +38,10 @@
|
||||
// Add guardian modal
|
||||
let showAddModal = $state(false);
|
||||
let newGuardianId = $state('');
|
||||
let selectedParentLabel = $state('');
|
||||
let newRelationshipType = $state('autre');
|
||||
let isSubmitting = $state(false);
|
||||
let parentSearchInput: { clear: () => void } | undefined = $state();
|
||||
|
||||
// Confirm remove
|
||||
let confirmRemoveId = $state<string | null>(null);
|
||||
@@ -92,7 +95,9 @@
|
||||
successMessage = 'Parent ajouté avec succès';
|
||||
showAddModal = false;
|
||||
newGuardianId = '';
|
||||
selectedParentLabel = '';
|
||||
newRelationshipType = 'autre';
|
||||
parentSearchInput?.clear();
|
||||
await loadGuardians();
|
||||
globalThis.setTimeout(() => { successMessage = null; }, 3000);
|
||||
} catch (e) {
|
||||
@@ -209,14 +214,23 @@
|
||||
onsubmit={(e) => { e.preventDefault(); addGuardian(); }}
|
||||
>
|
||||
<div class="form-group">
|
||||
<label for="guardianId">ID du parent</label>
|
||||
<input
|
||||
id="guardianId"
|
||||
type="text"
|
||||
bind:value={newGuardianId}
|
||||
placeholder="UUID du compte parent"
|
||||
required
|
||||
<label for="parent-search-input">Parent</label>
|
||||
<ParentSearchInput
|
||||
bind:this={parentSearchInput}
|
||||
excludeIds={guardians.map(g => g.guardianId)}
|
||||
onSelect={(parent) => {
|
||||
newGuardianId = parent.id;
|
||||
const name = `${parent.firstName} ${parent.lastName}`.trim();
|
||||
selectedParentLabel = name || parent.email;
|
||||
}}
|
||||
onClear={() => {
|
||||
newGuardianId = '';
|
||||
selectedParentLabel = '';
|
||||
}}
|
||||
/>
|
||||
{#if selectedParentLabel}
|
||||
<span class="selected-parent">Sélectionné : {selectedParentLabel}</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="relationshipType">Type de relation</label>
|
||||
@@ -466,7 +480,12 @@
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.selected-parent {
|
||||
font-size: 0.8125rem;
|
||||
color: #166534;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-group select {
|
||||
padding: 0.625rem 0.75rem;
|
||||
border: 1px solid #d1d5db;
|
||||
@@ -474,7 +493,6 @@
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus {
|
||||
outline: none;
|
||||
border-color: #3b82f6;
|
||||
|
||||
Reference in New Issue
Block a user