feat: Liaison parents-enfants avec gestion des tuteurs

Les parents doivent pouvoir suivre la scolarité de leurs enfants (notes,
emploi du temps, devoirs). Cela nécessite un lien formalisé entre le
compte parent et le compte élève, géré par les administrateurs.

Le lien est établi soit manuellement via l'interface d'administration,
soit automatiquement lors de l'activation du compte parent lorsque
l'invitation inclut un élève cible. Ce lien conditionne l'accès aux
données scolaires de l'enfant (autorisations vérifiées par un voter
dédié).
This commit is contained in:
2026-02-12 08:38:19 +01:00
parent e930c505df
commit 44ebe5e511
91 changed files with 10071 additions and 39 deletions

View File

@@ -0,0 +1,171 @@
<script lang="ts">
import { getApiBaseUrl } from '$lib/api/config';
import { authenticatedFetch } from '$lib/auth';
interface Child {
id: string;
studentId: string;
relationshipType: string;
relationshipLabel: string;
firstName: string;
lastName: string;
}
let {
onChildSelected
}: {
onChildSelected?: (childId: string) => void;
} = $props();
let children = $state<Child[]>([]);
let selectedChildId = $state<string | null>(null);
let isLoading = $state(true);
let error = $state<string | null>(null);
$effect(() => {
loadChildren();
});
async function loadChildren() {
try {
isLoading = true;
error = null;
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(`${apiUrl}/me/children`);
if (!response.ok) {
throw new Error('Impossible de charger les enfants');
}
const data = await response.json();
children = data['hydra:member'] ?? data['member'] ?? (Array.isArray(data) ? data : []);
const first = children[0];
if (first && !selectedChildId) {
selectedChildId = first.studentId;
onChildSelected?.(first.studentId);
}
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur inconnue';
} finally {
isLoading = false;
}
}
function selectChild(childId: string) {
selectedChildId = childId;
onChildSelected?.(childId);
}
</script>
{#if isLoading}
<div class="child-selector-loading">
<div class="spinner"></div>
</div>
{:else if error}
<div class="child-selector-error">{error}</div>
{:else if children.length === 1}
{#each children as child}
<div class="child-selector">
<span class="child-selector-label">Enfant :</span>
<span class="child-name">{child.firstName} {child.lastName}</span>
</div>
{/each}
{:else if children.length > 1}
<div class="child-selector">
<span class="child-selector-label">Enfant :</span>
<div class="child-selector-buttons">
{#each children as child (child.id)}
<button
class="child-button"
class:selected={selectedChildId === child.studentId}
onclick={() => selectChild(child.studentId)}
>
{child.firstName} {child.lastName}
</button>
{/each}
</div>
</div>
{/if}
<style>
.child-selector {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.75rem 1rem;
background: #eff6ff;
border: 1px solid #bfdbfe;
border-radius: 0.5rem;
}
.child-selector-label {
font-size: 0.875rem;
font-weight: 500;
color: #1e40af;
white-space: nowrap;
}
.child-name {
font-size: 0.875rem;
font-weight: 600;
color: #1f2937;
}
.child-selector-buttons {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.child-button {
padding: 0.375rem 0.75rem;
font-size: 0.875rem;
font-weight: 500;
background: white;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
cursor: pointer;
transition: all 0.15s;
}
.child-button:hover {
background: #f3f4f6;
}
.child-button.selected {
background: #3b82f6;
border-color: #3b82f6;
color: white;
}
.child-selector-loading {
display: flex;
justify-content: center;
padding: 0.5rem;
}
.spinner {
width: 1.25rem;
height: 1.25rem;
border: 2px solid #e5e7eb;
border-top-color: #3b82f6;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.child-selector-error {
padding: 0.5rem 1rem;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 0.5rem;
color: #991b1b;
font-size: 0.875rem;
}
</style>

View File

@@ -0,0 +1,521 @@
<script lang="ts">
import { getApiBaseUrl } from '$lib/api/config';
import { authenticatedFetch } from '$lib/auth';
interface Guardian {
id: string;
guardianId: string;
relationshipType: string;
relationshipLabel: string;
linkedAt: string;
firstName: string;
lastName: string;
email: string;
}
const RELATIONSHIP_OPTIONS = [
{ value: 'père', label: 'Père' },
{ value: 'mère', label: 'Mère' },
{ value: 'tuteur', label: 'Tuteur' },
{ value: 'tutrice', label: 'Tutrice' },
{ value: 'grand-père', label: 'Grand-père' },
{ value: 'grand-mère', label: 'Grand-mère' },
{ value: 'autre', label: 'Autre' }
];
let {
studentId
}: {
studentId: string;
} = $props();
let guardians = $state<Guardian[]>([]);
let isLoading = $state(true);
let error = $state<string | null>(null);
let successMessage = $state<string | null>(null);
// Add guardian modal
let showAddModal = $state(false);
let newGuardianId = $state('');
let newRelationshipType = $state('autre');
let isSubmitting = $state(false);
// Confirm remove
let confirmRemoveId = $state<string | null>(null);
let isRemoving = $state(false);
$effect(() => {
loadGuardians();
});
async function loadGuardians() {
try {
isLoading = true;
error = null;
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(`${apiUrl}/students/${studentId}/guardians`);
if (!response.ok) {
throw new Error('Erreur lors du chargement des parents');
}
const data = await response.json();
guardians = data['hydra:member'] ?? data['member'] ?? (Array.isArray(data) ? data : []);
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur inconnue';
} finally {
isLoading = false;
}
}
async function addGuardian() {
if (!newGuardianId.trim()) return;
try {
isSubmitting = true;
error = null;
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(`${apiUrl}/students/${studentId}/guardians`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
guardianId: newGuardianId,
relationshipType: newRelationshipType
})
});
if (!response.ok) {
const data = await response.json().catch(() => null);
throw new Error(data?.detail ?? data?.message ?? 'Erreur lors de l\'ajout du parent');
}
successMessage = 'Parent ajouté avec succès';
showAddModal = false;
newGuardianId = '';
newRelationshipType = 'autre';
await loadGuardians();
globalThis.setTimeout(() => { successMessage = null; }, 3000);
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur inconnue';
} finally {
isSubmitting = false;
}
}
async function removeGuardian(guardianId: string) {
try {
isRemoving = true;
error = null;
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(
`${apiUrl}/students/${studentId}/guardians/${guardianId}`,
{ method: 'DELETE' }
);
if (!response.ok) {
throw new Error('Erreur lors de la suppression de la liaison');
}
successMessage = 'Liaison supprimée';
confirmRemoveId = null;
await loadGuardians();
globalThis.setTimeout(() => { successMessage = null; }, 3000);
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur inconnue';
} finally {
isRemoving = false;
}
}
</script>
<section class="guardian-section">
<div class="section-header">
<h3>Parents / Tuteurs</h3>
{#if guardians.length < 2}
<button class="btn-add" onclick={() => { showAddModal = true; }}>
+ Ajouter un parent
</button>
{/if}
</div>
{#if error}
<div class="alert alert-error">{error}</div>
{/if}
{#if successMessage}
<div class="alert alert-success">{successMessage}</div>
{/if}
{#if isLoading}
<div class="loading">Chargement des parents...</div>
{:else if guardians.length === 0}
<p class="empty-state">Aucun parent/tuteur lié à cet élève.</p>
{:else}
<ul class="guardian-list">
{#each guardians as guardian (guardian.id)}
<li class="guardian-item">
<div class="guardian-info">
<span class="guardian-name">{guardian.firstName} {guardian.lastName}</span>
<span class="guardian-type">{guardian.relationshipLabel}</span>
<span class="guardian-email">{guardian.email}</span>
<span class="guardian-date">
Lié le {new Date(guardian.linkedAt).toLocaleDateString('fr-FR')}
</span>
</div>
<div class="guardian-actions">
{#if confirmRemoveId === guardian.guardianId}
<span class="confirm-text">Confirmer ?</span>
<button
class="btn-confirm-remove"
onclick={() => removeGuardian(guardian.guardianId)}
disabled={isRemoving}
>
{isRemoving ? '...' : 'Oui'}
</button>
<button class="btn-cancel" onclick={() => { confirmRemoveId = null; }}>
Non
</button>
{:else}
<button
class="btn-remove"
onclick={() => { confirmRemoveId = guardian.guardianId; }}
>
Retirer
</button>
{/if}
</div>
</li>
{/each}
</ul>
{/if}
</section>
{#if showAddModal}
<div class="modal-overlay" onclick={() => { showAddModal = false; }} role="presentation">
<!-- svelte-ignore a11y_interactive_supports_focus -->
<div class="modal" role="dialog" aria-modal="true" onclick={(e) => e.stopPropagation()}>
<header class="modal-header">
<h2>Ajouter un parent/tuteur</h2>
<button class="modal-close" onclick={() => { showAddModal = false; }}>&times;</button>
</header>
<form
class="modal-body"
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
/>
</div>
<div class="form-group">
<label for="relationshipType">Type de relation</label>
<select id="relationshipType" bind:value={newRelationshipType}>
{#each RELATIONSHIP_OPTIONS as option}
<option value={option.value}>{option.label}</option>
{/each}
</select>
</div>
<div class="modal-actions">
<button type="button" class="btn-secondary" onclick={() => { showAddModal = false; }}>
Annuler
</button>
<button type="submit" class="btn-primary" disabled={isSubmitting || !newGuardianId.trim()}>
{isSubmitting ? 'Ajout...' : 'Ajouter'}
</button>
</div>
</form>
</div>
</div>
{/if}
<style>
.guardian-section {
background: white;
border: 1px solid #e5e7eb;
border-radius: 0.75rem;
padding: 1.5rem;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.section-header h3 {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: #1f2937;
}
.btn-add {
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 500;
background: #3b82f6;
color: white;
border: none;
border-radius: 0.375rem;
cursor: pointer;
transition: background 0.15s;
}
.btn-add:hover {
background: #2563eb;
}
.alert {
padding: 0.75rem 1rem;
border-radius: 0.375rem;
font-size: 0.875rem;
margin-bottom: 1rem;
}
.alert-error {
background: #fef2f2;
border: 1px solid #fecaca;
color: #991b1b;
}
.alert-success {
background: #f0fdf4;
border: 1px solid #bbf7d0;
color: #166534;
}
.loading {
text-align: center;
color: #6b7280;
padding: 1rem;
}
.empty-state {
color: #6b7280;
font-size: 0.875rem;
text-align: center;
padding: 1rem;
}
.guardian-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.guardian-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1rem;
background: #f9fafb;
border-radius: 0.5rem;
gap: 1rem;
}
.guardian-info {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.guardian-name {
font-weight: 600;
color: #1f2937;
}
.guardian-type {
font-size: 0.75rem;
color: #6b7280;
}
.guardian-email {
font-size: 0.75rem;
color: #6b7280;
}
.guardian-date {
font-size: 0.75rem;
color: #9ca3af;
}
.guardian-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
.confirm-text {
font-size: 0.875rem;
color: #991b1b;
font-weight: 500;
}
.btn-remove {
padding: 0.375rem 0.75rem;
font-size: 0.75rem;
background: #fef2f2;
border: 1px solid #fecaca;
color: #991b1b;
border-radius: 0.25rem;
cursor: pointer;
transition: background 0.15s;
}
.btn-remove:hover {
background: #fee2e2;
}
.btn-confirm-remove {
padding: 0.375rem 0.75rem;
font-size: 0.75rem;
background: #dc2626;
border: none;
color: white;
border-radius: 0.25rem;
cursor: pointer;
}
.btn-confirm-remove:disabled {
opacity: 0.5;
}
.btn-cancel {
padding: 0.375rem 0.75rem;
font-size: 0.75rem;
background: #f3f4f6;
border: 1px solid #d1d5db;
color: #374151;
border-radius: 0.25rem;
cursor: pointer;
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
}
.modal {
background: white;
border-radius: 0.75rem;
width: 100%;
max-width: 28rem;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid #e5e7eb;
}
.modal-header h2 {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
}
.modal-close {
background: none;
border: none;
font-size: 1.5rem;
color: #6b7280;
cursor: pointer;
line-height: 1;
}
.modal-body {
padding: 1.5rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.form-group {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.form-group label {
font-size: 0.875rem;
font-weight: 500;
color: #374151;
}
.form-group input,
.form-group select {
padding: 0.625rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 0.875rem;
}
.form-group input:focus,
.form-group select:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding-top: 0.5rem;
}
.btn-primary {
padding: 0.625rem 1.25rem;
font-size: 0.875rem;
font-weight: 500;
background: #3b82f6;
color: white;
border: none;
border-radius: 0.375rem;
cursor: pointer;
transition: background 0.15s;
}
.btn-primary:hover:not(:disabled) {
background: #2563eb;
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-secondary {
padding: 0.625rem 1.25rem;
font-size: 0.875rem;
font-weight: 500;
background: white;
border: 1px solid #d1d5db;
color: #374151;
border-radius: 0.375rem;
cursor: pointer;
transition: background 0.15s;
}
.btn-secondary:hover {
background: #f3f4f6;
}
</style>