feat: Permettre aux enseignants de dupliquer un devoir vers plusieurs classes
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:
@@ -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">⚠</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 = [])}>×</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">×</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">⚠</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>
|
||||
|
||||
Reference in New Issue
Block a user