L'import manuel élève par élève est fastidieux pour les établissements qui gèrent des centaines d'élèves. Un wizard d'import en 4 étapes (upload → mapping → preview → confirmation) permet de traiter un fichier complet en une seule opération, avec détection automatique du format (Pronote, École Directe) et validation avant import. L'import est traité de manière asynchrone via Messenger pour ne pas bloquer l'interface, avec suivi de progression en temps réel et réutilisation des mappings entre imports successifs.
1304 lines
29 KiB
Svelte
1304 lines
29 KiB
Svelte
<script lang="ts">
|
||
import { goto } from '$app/navigation';
|
||
import { page } from '$app/state';
|
||
import Pagination from '$lib/components/molecules/Pagination/Pagination.svelte';
|
||
import SearchInput from '$lib/components/molecules/SearchInput/SearchInput.svelte';
|
||
import { SCHOOL_LEVEL_OPTIONS } from '$lib/constants/schoolLevels';
|
||
import {
|
||
fetchStudents as apiFetchStudents,
|
||
fetchClasses as apiFetchClasses,
|
||
createStudent as apiCreateStudent,
|
||
changeStudentClass as apiChangeStudentClass,
|
||
type Student,
|
||
type SchoolClass
|
||
} from '$lib/features/students/api/students';
|
||
import { untrack } from 'svelte';
|
||
|
||
// State
|
||
let students = $state<Student[]>([]);
|
||
let classes = $state<SchoolClass[]>([]);
|
||
let isLoading = $state(true);
|
||
let error = $state<string | null>(null);
|
||
let successMessage = $state<string | null>(null);
|
||
|
||
// Filters
|
||
let filterClassId = $state<string>(page.url.searchParams.get('classId') ?? '');
|
||
|
||
// Pagination & Search
|
||
let currentPage = $state(Number(page.url.searchParams.get('page')) || 1);
|
||
let searchTerm = $state(page.url.searchParams.get('search') ?? '');
|
||
let totalItems = $state(0);
|
||
const itemsPerPage = 30;
|
||
let totalPages = $derived(Math.ceil(totalItems / itemsPerPage));
|
||
|
||
// Create modal state
|
||
let showCreateModal = $state(false);
|
||
let newFirstName = $state('');
|
||
let newLastName = $state('');
|
||
let newEmail = $state('');
|
||
let newClassId = $state('');
|
||
let newDateNaissance = $state('');
|
||
let newStudentNumber = $state('');
|
||
let isSubmitting = $state(false);
|
||
let duplicateWarning = $state<string | null>(null);
|
||
let duplicateConfirmed = $state(false);
|
||
let createAnother = $state(false);
|
||
|
||
// Change class modal state
|
||
let showChangeClassModal = $state(false);
|
||
let changeClassTarget = $state<Student | null>(null);
|
||
let newClassForChange = $state('');
|
||
let isChangingClass = $state(false);
|
||
|
||
// Classes grouped by level for optgroup
|
||
let classesByLevel = $derived.by(() => {
|
||
const groups: Record<string, SchoolClass[]> = {};
|
||
const noLevel: SchoolClass[] = [];
|
||
for (const cls of classes) {
|
||
if (cls.level) {
|
||
const existing = groups[cls.level];
|
||
if (existing) {
|
||
existing.push(cls);
|
||
} else {
|
||
groups[cls.level] = [cls];
|
||
}
|
||
} else {
|
||
noLevel.push(cls);
|
||
}
|
||
}
|
||
// Order groups by SCHOOL_LEVEL_OPTIONS order
|
||
const ordered: { level: string; classes: SchoolClass[] }[] = [];
|
||
for (const opt of SCHOOL_LEVEL_OPTIONS) {
|
||
const group = groups[opt.value];
|
||
if (group) {
|
||
ordered.push({ level: opt.label, classes: group });
|
||
}
|
||
}
|
||
if (noLevel.length > 0) {
|
||
ordered.push({ level: 'Autre', classes: noLevel });
|
||
}
|
||
return ordered;
|
||
});
|
||
|
||
// Load data on mount
|
||
let loadAbortController: AbortController | null = null;
|
||
|
||
$effect(() => {
|
||
untrack(() => {
|
||
loadStudents();
|
||
loadClasses();
|
||
});
|
||
});
|
||
|
||
async function loadStudents() {
|
||
loadAbortController?.abort();
|
||
const controller = new AbortController();
|
||
loadAbortController = controller;
|
||
|
||
try {
|
||
isLoading = true;
|
||
error = null;
|
||
const result = await apiFetchStudents({
|
||
page: currentPage,
|
||
itemsPerPage,
|
||
search: searchTerm || undefined,
|
||
classId: filterClassId || undefined,
|
||
signal: controller.signal
|
||
});
|
||
|
||
if (controller.signal.aborted) return;
|
||
|
||
students = result.members;
|
||
totalItems = result.totalItems;
|
||
} catch (e) {
|
||
if (e instanceof DOMException && e.name === 'AbortError') return;
|
||
error = e instanceof Error ? e.message : 'Erreur inconnue';
|
||
students = [];
|
||
totalItems = 0;
|
||
} finally {
|
||
if (!controller.signal.aborted) {
|
||
isLoading = false;
|
||
}
|
||
}
|
||
}
|
||
|
||
async function loadClasses() {
|
||
try {
|
||
classes = await apiFetchClasses();
|
||
} catch {
|
||
// Non-blocking: classes will just be empty
|
||
}
|
||
}
|
||
|
||
function updateUrl() {
|
||
const params = new URLSearchParams();
|
||
if (currentPage > 1) params.set('page', String(currentPage));
|
||
if (searchTerm) params.set('search', searchTerm);
|
||
if (filterClassId) params.set('classId', filterClassId);
|
||
const query = params.toString();
|
||
goto(`?${query}`, { replaceState: true, noScroll: true, keepFocus: true });
|
||
}
|
||
|
||
function handleSearch(value: string) {
|
||
searchTerm = value;
|
||
currentPage = 1;
|
||
updateUrl();
|
||
loadStudents();
|
||
}
|
||
|
||
function handlePageChange(newPage: number) {
|
||
currentPage = newPage;
|
||
updateUrl();
|
||
loadStudents();
|
||
}
|
||
|
||
function handleFilterClass() {
|
||
currentPage = 1;
|
||
updateUrl();
|
||
loadStudents();
|
||
}
|
||
|
||
function resetFilters() {
|
||
filterClassId = '';
|
||
searchTerm = '';
|
||
currentPage = 1;
|
||
updateUrl();
|
||
loadStudents();
|
||
}
|
||
|
||
// --- Create student ---
|
||
|
||
function openCreateModal() {
|
||
showCreateModal = true;
|
||
newFirstName = '';
|
||
newLastName = '';
|
||
newEmail = '';
|
||
newClassId = '';
|
||
newDateNaissance = '';
|
||
newStudentNumber = '';
|
||
duplicateWarning = null;
|
||
duplicateConfirmed = false;
|
||
createAnother = false;
|
||
error = null;
|
||
}
|
||
|
||
function closeCreateModal() {
|
||
showCreateModal = false;
|
||
}
|
||
|
||
async function checkDuplicate(): Promise<boolean> {
|
||
if (duplicateConfirmed) return false;
|
||
const first = newFirstName.trim().toLowerCase();
|
||
const last = newLastName.trim().toLowerCase();
|
||
|
||
try {
|
||
const result = await apiFetchStudents({
|
||
page: 1,
|
||
itemsPerPage: 100,
|
||
search: newLastName.trim(),
|
||
classId: newClassId
|
||
});
|
||
const duplicate = result.members.find(
|
||
(s) => s.firstName.toLowerCase() === first && s.lastName.toLowerCase() === last
|
||
);
|
||
if (duplicate) {
|
||
duplicateWarning = `Un élève nommé ${duplicate.firstName} ${duplicate.lastName} existe déjà dans cette classe. Voulez-vous continuer ?`;
|
||
return true;
|
||
}
|
||
} catch {
|
||
// Non-blocking: skip duplicate check on error
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
function confirmDuplicate() {
|
||
duplicateConfirmed = true;
|
||
duplicateWarning = null;
|
||
handleCreateStudent();
|
||
}
|
||
|
||
function cancelDuplicate() {
|
||
duplicateWarning = null;
|
||
}
|
||
|
||
async function handleCreateStudent() {
|
||
if (!newFirstName.trim() || !newLastName.trim() || !newClassId) return;
|
||
|
||
if (await checkDuplicate()) return;
|
||
|
||
try {
|
||
isSubmitting = true;
|
||
error = null;
|
||
const created = await apiCreateStudent({
|
||
firstName: newFirstName.trim(),
|
||
lastName: newLastName.trim(),
|
||
classId: newClassId,
|
||
email: newEmail.trim() ? newEmail.trim().toLowerCase() : undefined,
|
||
dateNaissance: newDateNaissance || undefined,
|
||
studentNumber: newStudentNumber.trim() || undefined
|
||
});
|
||
|
||
// Optimistic update: only add to list if matching current filters
|
||
const matchesClassFilter = !filterClassId || created.classId === filterClassId;
|
||
const matchesSearch =
|
||
!searchTerm ||
|
||
created.firstName.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||
created.lastName.toLowerCase().includes(searchTerm.toLowerCase());
|
||
|
||
if (matchesClassFilter && matchesSearch) {
|
||
students = [created, ...students];
|
||
totalItems += 1;
|
||
}
|
||
|
||
const studentName = `${created.firstName} ${created.lastName}`;
|
||
if (newEmail.trim()) {
|
||
successMessage = `${studentName} a été créé et une invitation a été envoyée.`;
|
||
} else {
|
||
successMessage = `${studentName} a été inscrit avec succès.`;
|
||
}
|
||
|
||
if (createAnother) {
|
||
// Reset form for next creation
|
||
newFirstName = '';
|
||
newLastName = '';
|
||
newEmail = '';
|
||
newDateNaissance = '';
|
||
newStudentNumber = '';
|
||
duplicateWarning = null;
|
||
duplicateConfirmed = false;
|
||
// Keep classId and modal open
|
||
} else {
|
||
closeCreateModal();
|
||
}
|
||
} catch (e) {
|
||
error = e instanceof Error ? e.message : 'Erreur lors de la création';
|
||
} finally {
|
||
isSubmitting = false;
|
||
}
|
||
}
|
||
|
||
// --- Change class ---
|
||
|
||
function openChangeClassModal(student: Student) {
|
||
changeClassTarget = student;
|
||
newClassForChange = '';
|
||
showChangeClassModal = true;
|
||
error = null;
|
||
}
|
||
|
||
function closeChangeClassModal() {
|
||
showChangeClassModal = false;
|
||
changeClassTarget = null;
|
||
}
|
||
|
||
async function handleChangeClass() {
|
||
if (!changeClassTarget || !newClassForChange) return;
|
||
|
||
try {
|
||
isChangingClass = true;
|
||
error = null;
|
||
await apiChangeStudentClass(changeClassTarget.id, newClassForChange);
|
||
|
||
// Optimistic update
|
||
const targetClass = classes.find((c) => c.id === newClassForChange);
|
||
if (filterClassId && newClassForChange !== filterClassId) {
|
||
// Student no longer matches class filter — remove from list
|
||
students = students.filter((s) => s.id !== changeClassTarget!.id);
|
||
totalItems -= 1;
|
||
} else {
|
||
students = students.map((s) =>
|
||
s.id === changeClassTarget!.id
|
||
? {
|
||
...s,
|
||
classId: newClassForChange,
|
||
className: targetClass?.name ?? null,
|
||
classLevel: targetClass?.level ?? null
|
||
}
|
||
: s
|
||
);
|
||
}
|
||
|
||
successMessage = `${changeClassTarget.firstName} ${changeClassTarget.lastName} a été transféré vers ${targetClass?.name ?? 'la nouvelle classe'}.`;
|
||
closeChangeClassModal();
|
||
} catch (e) {
|
||
error = e instanceof Error ? e.message : 'Erreur lors du changement de classe';
|
||
} finally {
|
||
isChangingClass = false;
|
||
}
|
||
}
|
||
|
||
// --- Helpers ---
|
||
|
||
function getStatutLabel(statut: string): string {
|
||
switch (statut) {
|
||
case 'pending':
|
||
return 'En attente';
|
||
case 'active':
|
||
return 'Actif';
|
||
case 'inscrit':
|
||
return 'Inscrit';
|
||
case 'suspended':
|
||
return 'Suspendu';
|
||
default:
|
||
return statut;
|
||
}
|
||
}
|
||
|
||
function getStatutClass(statut: string): string {
|
||
switch (statut) {
|
||
case 'active':
|
||
return 'status-active';
|
||
case 'pending':
|
||
return 'status-pending';
|
||
case 'inscrit':
|
||
return 'status-inscrit';
|
||
case 'suspended':
|
||
return 'status-blocked';
|
||
default:
|
||
return '';
|
||
}
|
||
}
|
||
|
||
function isValidINE(value: string): boolean {
|
||
return /^[A-Za-z0-9]{11}$/.test(value);
|
||
}
|
||
|
||
let ineError = $derived(
|
||
newStudentNumber.trim() && !isValidINE(newStudentNumber.trim())
|
||
? "L'INE doit contenir exactement 11 caractères alphanumériques"
|
||
: null
|
||
);
|
||
|
||
let canSubmitCreate = $derived(
|
||
newFirstName.trim() !== '' &&
|
||
newLastName.trim() !== '' &&
|
||
newClassId !== '' &&
|
||
!isSubmitting &&
|
||
(!newStudentNumber.trim() || isValidINE(newStudentNumber.trim()))
|
||
);
|
||
</script>
|
||
|
||
<svelte:head>
|
||
<title>Gestion des élèves - Classeo</title>
|
||
</svelte:head>
|
||
|
||
<div class="students-page">
|
||
<header class="page-header">
|
||
<div class="header-content">
|
||
<h1>Gestion des élèves</h1>
|
||
<p class="subtitle">Créez et gérez les élèves de votre établissement</p>
|
||
</div>
|
||
<div class="header-actions">
|
||
<a href="/admin/import/students" class="btn-secondary">
|
||
Importer (CSV)
|
||
</a>
|
||
<button class="btn-primary" onclick={openCreateModal}>
|
||
<span class="btn-icon">+</span>
|
||
Nouvel élève
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
{#if error}
|
||
<div class="alert alert-error">
|
||
{error}
|
||
<button class="alert-close" onclick={() => (error = null)}>×</button>
|
||
</div>
|
||
{/if}
|
||
|
||
{#if successMessage}
|
||
<div class="alert alert-success">
|
||
{successMessage}
|
||
<button class="alert-close" onclick={() => (successMessage = null)}>×</button>
|
||
</div>
|
||
{/if}
|
||
|
||
<!-- Filters -->
|
||
<div class="filters-bar">
|
||
<div class="filter-group">
|
||
<label for="filter-class">Classe</label>
|
||
<select id="filter-class" bind:value={filterClassId} onchange={handleFilterClass}>
|
||
<option value="">Toutes les classes</option>
|
||
{#each classesByLevel as group}
|
||
<optgroup label={group.level}>
|
||
{#each group.classes as cls}
|
||
<option value={cls.id}>{cls.name}</option>
|
||
{/each}
|
||
</optgroup>
|
||
{/each}
|
||
</select>
|
||
</div>
|
||
<div class="filter-actions">
|
||
<button class="btn-text btn-sm" onclick={resetFilters}>Réinitialiser</button>
|
||
</div>
|
||
</div>
|
||
|
||
<SearchInput
|
||
value={searchTerm}
|
||
onSearch={handleSearch}
|
||
placeholder="Rechercher par nom, prénom..."
|
||
/>
|
||
|
||
{#if isLoading}
|
||
<div class="loading-state" aria-live="polite" role="status">
|
||
<div class="spinner"></div>
|
||
<p>Chargement des élèves...</p>
|
||
</div>
|
||
{:else if students.length === 0}
|
||
<div class="empty-state">
|
||
<span class="empty-icon">🎓</span>
|
||
{#if searchTerm || filterClassId}
|
||
<h2>Aucun résultat</h2>
|
||
<p>Aucun élève ne correspond à vos critères de recherche</p>
|
||
<button class="btn-secondary" onclick={resetFilters}>Réinitialiser les filtres</button>
|
||
{:else}
|
||
<h2>Aucun élève</h2>
|
||
<p>Commencez par créer votre premier élève</p>
|
||
<button class="btn-primary" onclick={openCreateModal}>Nouvel élève</button>
|
||
{/if}
|
||
</div>
|
||
{:else}
|
||
<div class="students-table-container">
|
||
<table class="students-table">
|
||
<thead>
|
||
<tr>
|
||
<th>Nom</th>
|
||
<th>Classe</th>
|
||
<th>Statut</th>
|
||
<th>Actions</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{#each students as student (student.id)}
|
||
<tr
|
||
class="clickable-row"
|
||
onclick={() => goto(`/admin/students/${student.id}`)}
|
||
role="link"
|
||
tabindex="0"
|
||
onkeydown={(e) => { if (e.key === 'Enter') goto(`/admin/students/${student.id}`); }}
|
||
>
|
||
<td data-label="Nom" class="student-name-cell">
|
||
<span class="student-fullname">{student.lastName} {student.firstName}</span>
|
||
</td>
|
||
<td data-label="Classe">
|
||
{#if student.className}
|
||
<span class="class-badge">{student.className}</span>
|
||
{:else}
|
||
<span class="no-class">Non affecté</span>
|
||
{/if}
|
||
</td>
|
||
<td data-label="Statut">
|
||
<span class="status-badge {getStatutClass(student.statut)}">
|
||
{getStatutLabel(student.statut)}
|
||
</span>
|
||
</td>
|
||
<td data-label="Actions" class="actions-cell">
|
||
<button
|
||
class="btn-secondary btn-sm"
|
||
onclick={(e) => { e.stopPropagation(); openChangeClassModal(student); }}
|
||
>
|
||
Changer de classe
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
{/each}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<Pagination {currentPage} {totalPages} onPageChange={handlePageChange} />
|
||
{/if}
|
||
</div>
|
||
|
||
<!-- Create Student Modal -->
|
||
{#if showCreateModal}
|
||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||
<div class="modal-overlay" onclick={closeCreateModal} role="presentation">
|
||
<div
|
||
class="modal"
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-labelledby="create-modal-title"
|
||
tabindex="-1"
|
||
onclick={(e) => e.stopPropagation()}
|
||
onkeydown={(e) => { if (e.key === 'Escape') closeCreateModal(); }}
|
||
>
|
||
<header class="modal-header">
|
||
<h2 id="create-modal-title">Nouvel élève</h2>
|
||
<button class="modal-close" onclick={closeCreateModal} aria-label="Fermer">×</button>
|
||
</header>
|
||
|
||
<form
|
||
class="modal-body"
|
||
onsubmit={(e) => {
|
||
e.preventDefault();
|
||
handleCreateStudent();
|
||
}}
|
||
>
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label for="student-firstname">Prénom *</label>
|
||
<input
|
||
type="text"
|
||
id="student-firstname"
|
||
bind:value={newFirstName}
|
||
placeholder="ex: Marie"
|
||
required
|
||
maxlength="100"
|
||
/>
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="student-lastname">Nom *</label>
|
||
<input
|
||
type="text"
|
||
id="student-lastname"
|
||
bind:value={newLastName}
|
||
placeholder="ex: Dupont"
|
||
required
|
||
maxlength="100"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="student-class">Classe *</label>
|
||
<select id="student-class" bind:value={newClassId} required>
|
||
<option value="">-- Sélectionner une classe --</option>
|
||
{#each classesByLevel as group}
|
||
<optgroup label={group.level}>
|
||
{#each group.classes as cls}
|
||
<option value={cls.id}>{cls.name}</option>
|
||
{/each}
|
||
</optgroup>
|
||
{/each}
|
||
</select>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="student-email">Email <span class="field-optional">(optionnel)</span></label>
|
||
<input
|
||
type="email"
|
||
id="student-email"
|
||
bind:value={newEmail}
|
||
placeholder="ex: marie.dupont@email.com"
|
||
/>
|
||
<span class="field-hint">Si fourni, une invitation sera envoyée automatiquement.</span>
|
||
</div>
|
||
|
||
<div class="form-row">
|
||
<div class="form-group">
|
||
<label for="student-dob">Date de naissance <span class="field-optional">(optionnel)</span></label>
|
||
<input
|
||
type="date"
|
||
id="student-dob"
|
||
bind:value={newDateNaissance}
|
||
/>
|
||
</div>
|
||
<div class="form-group">
|
||
<label for="student-ine">INE <span class="field-optional">(optionnel)</span></label>
|
||
<input
|
||
type="text"
|
||
id="student-ine"
|
||
bind:value={newStudentNumber}
|
||
placeholder="11 caractères"
|
||
maxlength="11"
|
||
/>
|
||
{#if ineError}
|
||
<span class="field-error">{ineError}</span>
|
||
{/if}
|
||
</div>
|
||
</div>
|
||
|
||
{#if duplicateWarning}
|
||
<div class="duplicate-warning">
|
||
<p>{duplicateWarning}</p>
|
||
<div class="duplicate-actions">
|
||
<button type="button" class="btn-secondary btn-sm" onclick={cancelDuplicate}>Annuler</button>
|
||
<button type="button" class="btn-primary btn-sm" onclick={confirmDuplicate}>Continuer</button>
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
|
||
<div class="modal-actions">
|
||
<label class="create-another-label">
|
||
<input type="checkbox" bind:checked={createAnother} />
|
||
<span>Créer un autre élève</span>
|
||
</label>
|
||
<div class="modal-actions-buttons">
|
||
<button type="button" class="btn-secondary" onclick={closeCreateModal} disabled={isSubmitting}>
|
||
Annuler
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
class="btn-primary"
|
||
disabled={!canSubmitCreate}
|
||
>
|
||
{#if isSubmitting}
|
||
Création...
|
||
{:else}
|
||
Créer l'élève
|
||
{/if}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
|
||
<!-- Change Class Modal -->
|
||
{#if showChangeClassModal && changeClassTarget}
|
||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||
<div class="modal-overlay" onclick={closeChangeClassModal} role="presentation">
|
||
<div
|
||
class="modal modal-confirm"
|
||
role="alertdialog"
|
||
aria-modal="true"
|
||
aria-labelledby="change-class-title"
|
||
aria-describedby="change-class-description"
|
||
tabindex="-1"
|
||
onclick={(e) => e.stopPropagation()}
|
||
onkeydown={(e) => { if (e.key === 'Escape') closeChangeClassModal(); }}
|
||
>
|
||
<header class="modal-header">
|
||
<h2 id="change-class-title">Changer de classe</h2>
|
||
<button class="modal-close" onclick={closeChangeClassModal} aria-label="Fermer">×</button>
|
||
</header>
|
||
|
||
<div class="modal-body">
|
||
<p id="change-class-description">
|
||
Transférer <strong>{changeClassTarget.firstName} {changeClassTarget.lastName}</strong>
|
||
{#if changeClassTarget.className}
|
||
(actuellement en {changeClassTarget.className})
|
||
{/if}
|
||
vers une autre classe :
|
||
</p>
|
||
|
||
<div class="form-group">
|
||
<label for="change-class-select">Nouvelle classe *</label>
|
||
<select id="change-class-select" bind:value={newClassForChange}>
|
||
<option value="">-- Sélectionner une classe --</option>
|
||
{#each classesByLevel as group}
|
||
<optgroup label={group.level}>
|
||
{#each group.classes as cls}
|
||
{#if cls.id !== changeClassTarget.classId}
|
||
<option value={cls.id}>{cls.name}</option>
|
||
{/if}
|
||
{/each}
|
||
</optgroup>
|
||
{/each}
|
||
</select>
|
||
</div>
|
||
|
||
{#if newClassForChange}
|
||
{@const targetClass = classes.find((c) => c.id === newClassForChange)}
|
||
<div class="change-confirm-info">
|
||
Transférer <strong>{changeClassTarget.firstName} {changeClassTarget.lastName}</strong> vers <strong>{targetClass?.name}</strong> ?
|
||
</div>
|
||
{/if}
|
||
|
||
<div class="modal-actions">
|
||
<button type="button" class="btn-secondary" onclick={closeChangeClassModal} disabled={isChangingClass}>
|
||
Annuler
|
||
</button>
|
||
<button
|
||
type="button"
|
||
class="btn-primary"
|
||
onclick={handleChangeClass}
|
||
disabled={isChangingClass || !newClassForChange}
|
||
>
|
||
{#if isChangingClass}
|
||
Transfert...
|
||
{:else}
|
||
Confirmer le transfert
|
||
{/if}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
{/if}
|
||
|
||
<style>
|
||
.students-page {
|
||
padding: 1.5rem;
|
||
max-width: 1200px;
|
||
margin: 0 auto;
|
||
}
|
||
|
||
.page-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: flex-start;
|
||
gap: 1rem;
|
||
margin-bottom: 1.5rem;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.header-actions {
|
||
display: flex;
|
||
gap: 0.75rem;
|
||
align-items: center;
|
||
}
|
||
|
||
.header-content h1 {
|
||
margin: 0;
|
||
font-size: 1.5rem;
|
||
font-weight: 700;
|
||
color: #1f2937;
|
||
}
|
||
|
||
.subtitle {
|
||
margin: 0.25rem 0 0;
|
||
color: #6b7280;
|
||
font-size: 0.875rem;
|
||
}
|
||
|
||
/* Buttons */
|
||
.btn-primary {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
padding: 0.75rem 1.25rem;
|
||
background: var(--btn-primary-bg, #3b82f6);
|
||
color: white;
|
||
border: none;
|
||
border-radius: 0.5rem;
|
||
font-weight: 600;
|
||
cursor: pointer;
|
||
transition: background 0.2s;
|
||
}
|
||
|
||
.btn-primary:hover:not(:disabled) {
|
||
background: var(--btn-primary-hover-bg, #2563eb);
|
||
}
|
||
|
||
.btn-primary:disabled {
|
||
opacity: 0.6;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.btn-secondary {
|
||
padding: 0.5rem 1rem;
|
||
background: white;
|
||
color: #374151;
|
||
border: 1px solid #d1d5db;
|
||
border-radius: 0.375rem;
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
.btn-secondary:hover:not(:disabled) {
|
||
background: #f3f4f6;
|
||
}
|
||
|
||
.btn-secondary:disabled {
|
||
opacity: 0.6;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.btn-text {
|
||
padding: 0.5rem 1rem;
|
||
background: none;
|
||
color: #6b7280;
|
||
border: none;
|
||
border-radius: 0.375rem;
|
||
font-weight: 500;
|
||
cursor: pointer;
|
||
transition: color 0.2s;
|
||
}
|
||
|
||
.btn-text:hover {
|
||
color: #374151;
|
||
}
|
||
|
||
.btn-sm {
|
||
padding: 0.375rem 0.75rem;
|
||
font-size: 0.875rem;
|
||
}
|
||
|
||
.btn-icon {
|
||
font-size: 1.25rem;
|
||
line-height: 1;
|
||
}
|
||
|
||
/* Alerts */
|
||
.alert {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.75rem;
|
||
padding: 1rem;
|
||
border-radius: 0.5rem;
|
||
margin-bottom: 1rem;
|
||
}
|
||
|
||
.alert-error {
|
||
background: #fef2f2;
|
||
border: 1px solid #fecaca;
|
||
color: #dc2626;
|
||
}
|
||
|
||
.alert-success {
|
||
background: #f0fdf4;
|
||
border: 1px solid #bbf7d0;
|
||
color: #16a34a;
|
||
}
|
||
|
||
.alert-close {
|
||
margin-left: auto;
|
||
padding: 0.25rem 0.5rem;
|
||
background: none;
|
||
border: none;
|
||
font-size: 1.25rem;
|
||
cursor: pointer;
|
||
opacity: 0.6;
|
||
}
|
||
|
||
.alert-close:hover {
|
||
opacity: 1;
|
||
}
|
||
|
||
/* Filters */
|
||
.filters-bar {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: stretch;
|
||
gap: 1rem;
|
||
margin-bottom: 1.5rem;
|
||
padding: 1rem;
|
||
background: white;
|
||
border: 1px solid #e5e7eb;
|
||
border-radius: 0.75rem;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.filter-group {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 0.25rem;
|
||
}
|
||
|
||
.filter-group label {
|
||
font-size: 0.75rem;
|
||
font-weight: 500;
|
||
color: #6b7280;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.05em;
|
||
}
|
||
|
||
.filter-group select {
|
||
padding: 0.5rem 0.75rem;
|
||
border: 1px solid #d1d5db;
|
||
border-radius: 0.375rem;
|
||
font-size: 0.875rem;
|
||
min-width: auto;
|
||
width: 100%;
|
||
background: white;
|
||
}
|
||
|
||
.filter-group select:focus {
|
||
outline: none;
|
||
border-color: #3b82f6;
|
||
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
|
||
}
|
||
|
||
.filter-actions {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
align-items: center;
|
||
}
|
||
|
||
/* Loading & Empty */
|
||
.loading-state,
|
||
.empty-state {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 3rem;
|
||
text-align: center;
|
||
background: white;
|
||
border-radius: 0.75rem;
|
||
border: 2px dashed #e5e7eb;
|
||
}
|
||
|
||
.spinner {
|
||
width: 2rem;
|
||
height: 2rem;
|
||
border: 3px solid #e5e7eb;
|
||
border-top-color: #3b82f6;
|
||
border-radius: 50%;
|
||
animation: spin 0.8s linear infinite;
|
||
}
|
||
|
||
@keyframes spin {
|
||
to {
|
||
transform: rotate(360deg);
|
||
}
|
||
}
|
||
|
||
.empty-icon {
|
||
font-size: 3rem;
|
||
margin-bottom: 1rem;
|
||
}
|
||
|
||
.empty-state h2 {
|
||
margin: 0 0 0.5rem;
|
||
font-size: 1.25rem;
|
||
color: #1f2937;
|
||
}
|
||
|
||
.empty-state p {
|
||
margin: 0 0 1.5rem;
|
||
color: #6b7280;
|
||
}
|
||
|
||
/* Table */
|
||
.students-table-container {
|
||
background: white;
|
||
border: 1px solid #e5e7eb;
|
||
border-radius: 0.75rem;
|
||
overflow-x: auto;
|
||
}
|
||
|
||
.students-table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
}
|
||
|
||
.students-table th {
|
||
text-align: left;
|
||
padding: 0.75rem 1rem;
|
||
font-size: 0.75rem;
|
||
font-weight: 600;
|
||
color: #6b7280;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.05em;
|
||
border-bottom: 1px solid #e5e7eb;
|
||
background: #f9fafb;
|
||
}
|
||
|
||
.students-table td {
|
||
padding: 0.75rem 1rem;
|
||
font-size: 0.875rem;
|
||
color: #374151;
|
||
border-bottom: 1px solid #f3f4f6;
|
||
}
|
||
|
||
.students-table tr:last-child td {
|
||
border-bottom: none;
|
||
}
|
||
|
||
.clickable-row {
|
||
cursor: pointer;
|
||
}
|
||
|
||
.clickable-row:hover td {
|
||
background: #f9fafb;
|
||
}
|
||
|
||
.student-name-cell {
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.student-fullname {
|
||
font-weight: 500;
|
||
color: #1f2937;
|
||
}
|
||
|
||
.class-badge {
|
||
display: inline-block;
|
||
padding: 0.125rem 0.5rem;
|
||
background: #eff6ff;
|
||
color: #3b82f6;
|
||
border-radius: 9999px;
|
||
font-size: 0.75rem;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.no-class {
|
||
color: #9ca3af;
|
||
font-style: italic;
|
||
font-size: 0.8125rem;
|
||
}
|
||
|
||
.actions-cell {
|
||
white-space: nowrap;
|
||
}
|
||
|
||
/* Badges */
|
||
.status-badge {
|
||
display: inline-block;
|
||
padding: 0.125rem 0.5rem;
|
||
border-radius: 9999px;
|
||
font-size: 0.75rem;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.status-active {
|
||
background: #f0fdf4;
|
||
color: #16a34a;
|
||
}
|
||
|
||
.status-pending {
|
||
background: #fffbeb;
|
||
color: #d97706;
|
||
}
|
||
|
||
.status-inscrit {
|
||
background: #eff6ff;
|
||
color: #3b82f6;
|
||
}
|
||
|
||
.status-blocked {
|
||
background: #fef2f2;
|
||
color: #dc2626;
|
||
}
|
||
|
||
/* Modal */
|
||
.modal-overlay {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(0, 0, 0, 0.5);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
padding: 1rem;
|
||
z-index: 100;
|
||
}
|
||
|
||
.modal {
|
||
background: white;
|
||
border-radius: 0.75rem;
|
||
width: 100%;
|
||
max-width: 32rem;
|
||
max-height: 90vh;
|
||
overflow: auto;
|
||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||
}
|
||
|
||
.modal-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 1.25rem 1.5rem;
|
||
border-bottom: 1px solid #e5e7eb;
|
||
}
|
||
|
||
.modal-header h2 {
|
||
margin: 0;
|
||
font-size: 1.125rem;
|
||
font-weight: 600;
|
||
color: #1f2937;
|
||
}
|
||
|
||
.modal-close {
|
||
padding: 0.25rem 0.5rem;
|
||
background: none;
|
||
border: none;
|
||
font-size: 1.5rem;
|
||
line-height: 1;
|
||
color: #6b7280;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.modal-close:hover {
|
||
color: #1f2937;
|
||
}
|
||
|
||
.modal-body {
|
||
padding: 1.5rem;
|
||
}
|
||
|
||
.form-row {
|
||
display: grid;
|
||
grid-template-columns: 1fr;
|
||
gap: 1rem;
|
||
align-items: end;
|
||
}
|
||
|
||
.form-group {
|
||
margin-bottom: 1.25rem;
|
||
}
|
||
|
||
.form-group label {
|
||
display: block;
|
||
margin-bottom: 0.5rem;
|
||
font-weight: 500;
|
||
color: #374151;
|
||
}
|
||
|
||
.form-group input,
|
||
.form-group select {
|
||
width: 100%;
|
||
padding: 0.75rem 1rem;
|
||
border: 1px solid #d1d5db;
|
||
border-radius: 0.375rem;
|
||
font-size: 1rem;
|
||
transition: border-color 0.2s;
|
||
background: white;
|
||
}
|
||
|
||
.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);
|
||
}
|
||
|
||
.field-optional {
|
||
font-weight: 400;
|
||
color: #9ca3af;
|
||
font-size: 0.875rem;
|
||
}
|
||
|
||
.field-hint {
|
||
display: block;
|
||
margin-top: 0.25rem;
|
||
font-size: 0.8125rem;
|
||
color: #9ca3af;
|
||
}
|
||
|
||
.field-error {
|
||
display: block;
|
||
margin-top: 0.25rem;
|
||
font-size: 0.8125rem;
|
||
color: #dc2626;
|
||
}
|
||
|
||
.duplicate-warning {
|
||
padding: 0.75rem 1rem;
|
||
background: #fffbeb;
|
||
border: 1px solid #fde68a;
|
||
border-radius: 0.375rem;
|
||
font-size: 0.875rem;
|
||
color: #92400e;
|
||
margin-bottom: 1.25rem;
|
||
}
|
||
|
||
.duplicate-warning p {
|
||
margin: 0 0 0.75rem;
|
||
}
|
||
|
||
.duplicate-actions {
|
||
display: flex;
|
||
gap: 0.5rem;
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
.change-confirm-info {
|
||
padding: 0.75rem 1rem;
|
||
background: #eff6ff;
|
||
border-radius: 0.375rem;
|
||
font-size: 0.875rem;
|
||
color: #3b82f6;
|
||
margin-bottom: 1.25rem;
|
||
}
|
||
|
||
.modal-actions {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: 0.75rem;
|
||
padding-top: 1rem;
|
||
border-top: 1px solid #e5e7eb;
|
||
}
|
||
|
||
.modal-actions-buttons {
|
||
display: flex;
|
||
gap: 0.75rem;
|
||
}
|
||
|
||
.create-another-label {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 0.5rem;
|
||
font-size: 0.875rem;
|
||
color: #6b7280;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.create-another-label input[type='checkbox'] {
|
||
width: 1rem;
|
||
height: 1rem;
|
||
accent-color: #3b82f6;
|
||
}
|
||
|
||
.modal-confirm .modal-actions {
|
||
justify-content: flex-end;
|
||
}
|
||
|
||
@media (min-width: 768px) {
|
||
.filters-bar {
|
||
flex-direction: row;
|
||
align-items: flex-end;
|
||
}
|
||
|
||
.filter-group select {
|
||
min-width: 200px;
|
||
width: auto;
|
||
}
|
||
|
||
.form-row {
|
||
grid-template-columns: 1fr 1fr;
|
||
}
|
||
}
|
||
|
||
@media (max-width: 767px) {
|
||
.students-table-container {
|
||
background: transparent;
|
||
border: none;
|
||
}
|
||
|
||
.students-table thead {
|
||
display: none;
|
||
}
|
||
|
||
.students-table tbody tr {
|
||
display: block;
|
||
background: white;
|
||
border: 1px solid #e5e7eb;
|
||
border-radius: 0.75rem;
|
||
margin-bottom: 0.75rem;
|
||
padding: 0.25rem 0;
|
||
}
|
||
|
||
.clickable-row:hover td {
|
||
background: transparent;
|
||
}
|
||
|
||
.students-table td {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: baseline;
|
||
padding: 0.5rem 1rem;
|
||
border-bottom: none;
|
||
text-align: right;
|
||
}
|
||
|
||
.students-table td::before {
|
||
content: attr(data-label);
|
||
font-weight: 600;
|
||
font-size: 0.75rem;
|
||
color: #6b7280;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.05em;
|
||
text-align: left;
|
||
margin-right: 1rem;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.student-name-cell {
|
||
white-space: normal;
|
||
}
|
||
|
||
.actions-cell {
|
||
justify-content: flex-end;
|
||
flex-wrap: wrap;
|
||
padding-top: 0.75rem;
|
||
border-top: 1px solid #f3f4f6;
|
||
}
|
||
}
|
||
</style>
|