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

@@ -401,6 +401,202 @@ test.describe('Homework Management (Story 5.1)', () => {
});
});
// ============================================================================
// AC: Duplicate Homework (Story 5.2)
// ============================================================================
test.describe('Story 5.2: Duplicate Homework', () => {
test.beforeAll(async () => {
// Ensure a second class exists for duplication
const { schoolId, academicYearId } = resolveDeterministicIds();
try {
runSql(
`INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) ` +
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E-HW-6B', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
} catch {
// May already exist
}
seedTeacherAssignments();
clearCache();
});
async function createHomework(page: import('@playwright/test').Page, title: string) {
await page.getByRole('button', { name: /nouveau devoir/i }).click();
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
await page.locator('#hw-class').selectOption({ index: 1 });
await page.locator('#hw-subject').selectOption({ index: 1 });
await page.locator('#hw-title').fill(title);
await page.locator('#hw-due-date').fill(getNextWeekday(5));
await page.getByRole('button', { name: /créer le devoir/i }).click();
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
await expect(page.getByText(title)).toBeVisible({ timeout: 10000 });
}
test('duplicate button is visible on homework card', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
await createHomework(page, 'Devoir dupliquer test');
await expect(page.getByRole('button', { name: /dupliquer/i })).toBeVisible();
});
test('opens duplicate modal with class selection', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
await createHomework(page, 'Devoir à dupliquer');
await page.getByRole('button', { name: /dupliquer/i }).first().click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });
await expect(dialog.getByText(/dupliquer le devoir/i)).toBeVisible();
await expect(dialog.getByText('Devoir à dupliquer')).toBeVisible();
});
test('shows target classes checkboxes excluding source class', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
await createHomework(page, 'Devoir classes cibles');
await page.getByRole('button', { name: /dupliquer/i }).first().click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });
// Should show checkboxes for target classes
const checkboxes = dialog.locator('input[type="checkbox"]');
await expect(checkboxes.first()).toBeVisible({ timeout: 5000 });
});
test('can duplicate homework to a target class', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
await createHomework(page, 'Devoir duplication réussie');
await page.getByRole('button', { name: /dupliquer/i }).first().click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });
// Select first target class
const firstCheckbox = dialog.locator('input[type="checkbox"]').first();
await firstCheckbox.check();
// Click duplicate button
await page.getByRole('button', { name: /dupliquer \(1 classe\)/i }).click();
await expect(dialog).not.toBeVisible({ timeout: 10000 });
// Homework should now appear twice (original + duplicate)
await expect(page.getByText('Devoir duplication réussie')).toHaveCount(2, { timeout: 10000 });
});
test('duplicate button is disabled when no class selected', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
await createHomework(page, 'Devoir sans sélection');
await page.getByRole('button', { name: /dupliquer/i }).first().click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });
// Duplicate button should be disabled
const duplicateBtn = dialog.getByRole('button', { name: /dupliquer/i }).last();
await expect(duplicateBtn).toBeDisabled();
});
test('cancel closes the duplicate modal', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
await createHomework(page, 'Devoir annulation dupli');
await page.getByRole('button', { name: /dupliquer/i }).first().click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });
await dialog.getByRole('button', { name: /annuler/i }).click();
await expect(dialog).not.toBeVisible({ timeout: 5000 });
});
test('can duplicate homework to multiple classes', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
await createHomework(page, 'Devoir multi-dupli');
await page.getByRole('button', { name: /dupliquer/i }).first().click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });
// Select ALL available checkboxes (at least 2 target classes)
const checkboxes = dialog.locator('input[type="checkbox"]');
const count = await checkboxes.count();
expect(count).toBeGreaterThanOrEqual(1);
for (let i = 0; i < count; i++) {
await checkboxes.nth(i).check();
}
// Verify button text reflects the count
const duplicateButton = dialog.getByRole('button', {
name: new RegExp(`dupliquer \\(${count} classes?\\)`, 'i')
});
await expect(duplicateButton).toBeVisible();
await expect(duplicateButton).toBeEnabled();
// Click duplicate
await duplicateButton.click();
await expect(dialog).not.toBeVisible({ timeout: 10000 });
// Verify the homework title appears count+1 times (original + duplicates)
await expect(page.getByText('Devoir multi-dupli')).toHaveCount(count + 1, { timeout: 10000 });
});
test('can customize due date per target class', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
await createHomework(page, 'Devoir date custom');
await page.getByRole('button', { name: /dupliquer/i }).first().click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });
// Select the first checkbox
const firstCheckbox = dialog.locator('input[type="checkbox"]').first();
await firstCheckbox.check();
// A date input should appear after checking the checkbox
const dueDateInput = dialog.locator('input[type="date"]').first();
await expect(dueDateInput).toBeVisible({ timeout: 5000 });
// Change the due date to a custom value
await dueDateInput.fill(getNextWeekday(10));
// Click duplicate
await page.getByRole('button', { name: /dupliquer \(1 classe\)/i }).click();
await expect(dialog).not.toBeVisible({ timeout: 10000 });
// Verify the homework appears twice (original + 1 duplicate)
await expect(page.getByText('Devoir date custom')).toHaveCount(2, { timeout: 10000 });
});
test('can filter homework list by class', async ({ page }) => {
await loginAsTeacher(page);
await navigateToHomework(page);
await createHomework(page, 'Devoir filtre classe');
// Class filter should be visible
const classFilter = page.locator('select[aria-label="Filtrer par classe"]');
await expect(classFilter).toBeVisible();
// Select a class to filter
await classFilter.selectOption({ index: 1 });
// Should still show homework (it matches the filter)
await expect(page.getByText('Devoir filtre classe')).toBeVisible({ timeout: 5000 });
// Select "Toutes les classes" to reset
await classFilter.selectOption({ index: 0 });
await expect(page.getByText('Devoir filtre classe')).toBeVisible({ timeout: 5000 });
});
});
// ============================================================================
// Partial Update
// ============================================================================

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>