feat: Permettre aux enseignants de dupliquer un devoir vers plusieurs classes
Some checks failed
CI / Backend Tests (push) Has been cancelled
CI / Frontend Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Naming Conventions (push) Has been cancelled
CI / Build Check (push) Has been cancelled

Un enseignant qui donne le même travail à plusieurs classes devait
jusqu'ici recréer manuellement chaque devoir. La duplication permet
de sélectionner les classes cibles, d'ajuster les dates d'échéance
par classe, et de créer tous les devoirs en une seule opération
atomique (transaction).

La validation s'effectue par classe (affectation enseignant, date
d'échéance) avec un rapport d'erreurs détaillé. L'infrastructure
de warnings est prête pour les règles de timing de la Story 5.3.
Le filtrage par classe dans la liste des devoirs passe côté serveur
pour rester compatible avec la pagination.
This commit is contained in:
2026-03-15 14:20:48 +01:00
parent e9efb90f59
commit 68179a929f
18 changed files with 1831 additions and 2 deletions

View File

@@ -77,6 +77,19 @@
let homeworkToDelete = $state<Homework | null>(null);
let isDeleting = $state(false);
// Duplicate modal
let showDuplicateModal = $state(false);
let homeworkToDuplicate = $state<Homework | null>(null);
let selectedTargetClassIds = $state<string[]>([]);
let dueDatesByClass = $state<Record<string, string>>({});
let isDuplicating = $state(false);
let duplicateError = $state<string | null>(null);
let duplicateValidationResults = $state<Array<{ classId: string; valid: boolean; error: string | null }>>([]);
let duplicateWarnings = $state<Array<{ classId: string; warning: string }>>([]);
// Class filter
let filterClassId = $state(page.url.searchParams.get('classId') ?? '');
// Derived: available subjects for selected class
let availableSubjectsForCreate = $derived.by(() => {
if (!newClassId) return [];
@@ -162,6 +175,7 @@
params.set('page', String(currentPage));
params.set('itemsPerPage', String(itemsPerPage));
if (searchTerm) params.set('search', searchTerm);
if (filterClassId) params.set('classId', filterClassId);
const response = await authenticatedFetch(`${apiUrl}/homework?${params.toString()}`, {
signal: controller.signal,
@@ -186,6 +200,7 @@
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 });
}
@@ -237,6 +252,26 @@
return classes.filter((c) => assignedClassIds.includes(c.id));
});
// Available target classes for duplication (same subject, exclude source class)
let availableTargetClasses = $derived.by(() => {
if (!homeworkToDuplicate) return [];
const sourceSubjectId = homeworkToDuplicate.subjectId;
const sourceClassId = homeworkToDuplicate.classId;
const assignedClassIds = assignments
.filter((a) => a.status === 'active' && a.subjectId === sourceSubjectId)
.map((a) => a.classId);
return classes.filter((c) => assignedClassIds.includes(c.id) && c.id !== sourceClassId);
});
function handleClassFilter(newClassId: string) {
filterClassId = newClassId;
currentPage = 1;
updateUrl();
loadHomeworks().catch((e) => {
error = e instanceof Error ? e.message : 'Erreur inconnue';
});
}
// --- Create ---
function openCreateModal() {
showCreateModal = true;
@@ -339,6 +374,86 @@
}
}
// --- Duplicate ---
function openDuplicateModal(hw: Homework) {
homeworkToDuplicate = hw;
selectedTargetClassIds = [];
dueDatesByClass = {};
duplicateError = null;
duplicateValidationResults = [];
duplicateWarnings = [];
showDuplicateModal = true;
}
function closeDuplicateModal() {
showDuplicateModal = false;
homeworkToDuplicate = null;
}
function toggleTargetClass(classId: string) {
if (selectedTargetClassIds.includes(classId)) {
selectedTargetClassIds = selectedTargetClassIds.filter((id) => id !== classId);
const { [classId]: _, ...rest } = dueDatesByClass;
dueDatesByClass = rest;
} else {
selectedTargetClassIds = [...selectedTargetClassIds, classId];
}
}
async function handleDuplicate() {
if (!homeworkToDuplicate || selectedTargetClassIds.length === 0) return;
try {
isDuplicating = true;
duplicateError = null;
duplicateValidationResults = [];
const apiUrl = getApiBaseUrl();
const dueDates: Record<string, string> = {};
for (const classId of selectedTargetClassIds) {
if (dueDatesByClass[classId]) {
dueDates[classId] = dueDatesByClass[classId];
}
}
const response = await authenticatedFetch(`${apiUrl}/homework/${homeworkToDuplicate.id}/duplicate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
targetClassIds: selectedTargetClassIds,
dueDates: Object.keys(dueDates).length > 0 ? dueDates : undefined,
}),
});
if (!response.ok) {
const errorData = await response.json().catch(() => null);
if (errorData?.results) {
duplicateValidationResults = errorData.results;
duplicateError = 'Certaines classes ne passent pas la validation.';
return;
}
throw new Error(
errorData?.['hydra:description'] ??
errorData?.error ??
errorData?.message ??
`Erreur lors de la duplication (${response.status})`,
);
}
const responseData = await response.json();
if (responseData?.warnings?.length > 0) {
duplicateWarnings = responseData.warnings;
}
closeDuplicateModal();
await loadHomeworks();
} catch (e) {
duplicateError = e instanceof Error ? e.message : 'Erreur lors de la duplication';
} finally {
isDuplicating = false;
}
}
// --- Delete ---
function openDeleteModal(hw: Homework) {
homeworkToDelete = hw;
@@ -405,7 +520,36 @@
</div>
{/if}
<SearchInput value={searchTerm} onSearch={handleSearch} placeholder="Rechercher par titre..." />
{#if duplicateWarnings.length > 0}
<div class="alert alert-warning">
<span class="alert-icon">&#9888;</span>
<div>
<strong>Duplication effectuée avec avertissements :</strong>
<ul class="warning-list">
{#each duplicateWarnings as w}
<li>{getClassName(w.classId)} : {w.warning}</li>
{/each}
</ul>
</div>
<button class="alert-close" onclick={() => (duplicateWarnings = [])}>&times;</button>
</div>
{/if}
<div class="filters-row">
<SearchInput value={searchTerm} onSearch={handleSearch} placeholder="Rechercher par titre..." />
<div class="class-filter">
<select
value={filterClassId}
aria-label="Filtrer par classe"
onchange={(e) => handleClassFilter((e.target as HTMLSelectElement).value)}
>
<option value="">Toutes les classes</option>
{#each availableClasses as cls (cls.id)}
<option value={cls.id}>{cls.name}</option>
{/each}
</select>
</div>
</div>
{#if isLoading}
<div class="loading-state" aria-live="polite" role="status">
@@ -462,6 +606,9 @@
<button class="btn-secondary btn-sm" onclick={() => openEditModal(hw)}>
Modifier
</button>
<button class="btn-secondary btn-sm" onclick={() => openDuplicateModal(hw)}>
Dupliquer
</button>
<button class="btn-danger btn-sm" onclick={() => openDeleteModal(hw)}>
Supprimer
</button>
@@ -699,6 +846,103 @@
</div>
{/if}
<!-- Duplicate Modal -->
{#if showDuplicateModal && homeworkToDuplicate}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="modal-overlay" onclick={closeDuplicateModal} role="presentation">
<div
class="modal"
role="dialog"
aria-modal="true"
aria-labelledby="duplicate-modal-title"
tabindex="-1"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => { if (e.key === 'Escape') closeDuplicateModal(); }}
>
<header class="modal-header">
<h2 id="duplicate-modal-title">Dupliquer le devoir</h2>
<button class="modal-close" onclick={closeDuplicateModal} aria-label="Fermer">&times;</button>
</header>
<div class="modal-body">
<div class="form-info">
<span class="info-label">Devoir :</span>
<span>{homeworkToDuplicate.title}</span>
<span class="info-label">Classe source :</span>
<span>{homeworkToDuplicate.className ?? getClassName(homeworkToDuplicate.classId)}</span>
<span class="info-label">Matière :</span>
<span>{homeworkToDuplicate.subjectName ?? getSubjectName(homeworkToDuplicate.subjectId)}</span>
</div>
{#if duplicateError}
<div class="alert alert-error" style="margin-bottom: 1rem;">
<span class="alert-icon">&#9888;</span>
{duplicateError}
</div>
{/if}
{#if availableTargetClasses.length === 0}
<p class="empty-target-classes">Aucune autre classe disponible pour cette matière.</p>
{:else}
<div class="form-group">
<label>Classes cibles *</label>
<div class="checkbox-list">
{#each availableTargetClasses as cls (cls.id)}
{@const validationResult = duplicateValidationResults.find((r) => r.classId === cls.id)}
<div class="checkbox-item" class:validation-error={validationResult && !validationResult.valid}>
<label class="checkbox-label">
<input
type="checkbox"
checked={selectedTargetClassIds.includes(cls.id)}
onchange={() => toggleTargetClass(cls.id)}
/>
{cls.name}
</label>
{#if selectedTargetClassIds.includes(cls.id)}
<div class="due-date-inline">
<label for="due-{cls.id}">Date :</label>
<input
type="date"
id="due-{cls.id}"
value={dueDatesByClass[cls.id] ?? homeworkToDuplicate.dueDate}
min={minDueDate}
onchange={(e) => {
dueDatesByClass = { ...dueDatesByClass, [cls.id]: (e.target as HTMLInputElement).value };
}}
/>
</div>
{/if}
{#if validationResult && !validationResult.valid}
<small class="form-hint form-hint-warning">{validationResult.error}</small>
{/if}
</div>
{/each}
</div>
</div>
{/if}
<div class="modal-actions">
<button type="button" class="btn-secondary" onclick={closeDuplicateModal} disabled={isDuplicating}>
Annuler
</button>
<button
type="button"
class="btn-primary"
onclick={handleDuplicate}
disabled={isDuplicating || selectedTargetClassIds.length === 0}
>
{#if isDuplicating}
Duplication...
{:else}
Dupliquer ({selectedTargetClassIds.length} classe{selectedTargetClassIds.length > 1 ? 's' : ''})
{/if}
</button>
</div>
</div>
</div>
</div>
{/if}
<style>
.homework-page {
padding: 1.5rem;
@@ -826,6 +1070,18 @@
opacity: 1;
}
.alert-warning {
background: #fffbeb;
border: 1px solid #fde68a;
color: #92400e;
}
.warning-list {
margin: 0.25rem 0 0;
padding-left: 1.25rem;
font-size: 0.875rem;
}
/* Loading & Empty states */
.loading-state,
.empty-state {
@@ -1125,4 +1381,91 @@
font-size: 0.875rem;
color: #6b7280;
}
/* Filters row */
.filters-row {
display: flex;
gap: 1rem;
align-items: flex-start;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.filters-row > :first-child {
flex: 1;
min-width: 200px;
}
.class-filter select {
padding: 0.625rem 1rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 0.875rem;
background: white;
cursor: pointer;
}
.class-filter select:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
/* Duplicate modal */
.checkbox-list {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.checkbox-item {
padding: 0.75rem;
border: 1px solid #e5e7eb;
border-radius: 0.375rem;
}
.checkbox-item.validation-error {
border-color: #fecaca;
background: #fef2f2;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
font-weight: 500;
}
.checkbox-label input[type='checkbox'] {
width: 1rem;
height: 1rem;
}
.due-date-inline {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.5rem;
padding-left: 1.5rem;
}
.due-date-inline label {
font-size: 0.875rem;
color: #6b7280;
white-space: nowrap;
}
.due-date-inline input[type='date'] {
padding: 0.375rem 0.5rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 0.875rem;
}
.empty-target-classes {
text-align: center;
color: #6b7280;
padding: 2rem 0;
}
</style>