Files
Classeo/frontend/src/routes/admin/students/+page.svelte
Mathias STRASSER 2420e35492 feat: Permettre l'import d'élèves via fichier CSV ou XLSX
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.
2026-02-25 16:51:13 +01:00

1304 lines
29 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>