feat: Permettre à l'élève de rendre un devoir avec réponse texte et pièces jointes
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

L'élève peut désormais répondre à un devoir via un éditeur WYSIWYG,
joindre des fichiers (PDF, JPEG, PNG, DOCX), sauvegarder un brouillon
et soumettre définitivement son rendu. Le système détecte automatiquement
les soumissions en retard par rapport à la date d'échéance.

Côté enseignant, une page dédiée affiche la liste complète des élèves
avec leur statut (soumis, en retard, brouillon, non rendu), le détail
de chaque rendu avec ses pièces jointes téléchargeables, et les
statistiques de rendus par classe.
This commit is contained in:
2026-03-25 19:38:25 +01:00
parent ab835e5c3d
commit df25a8cbb0
48 changed files with 4519 additions and 12 deletions

View File

@@ -1,5 +1,7 @@
<script lang="ts">
const ACCEPTED_TYPES = ['application/pdf', 'image/jpeg', 'image/png'];
const DEFAULT_ACCEPTED_TYPES = ['application/pdf', 'image/jpeg', 'image/png'];
const DEFAULT_ACCEPT_ATTR = '.pdf,.jpg,.jpeg,.png';
const DEFAULT_HINT = 'PDF, JPEG ou PNG — 10 Mo max par fichier';
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 Mo
interface UploadedFile {
@@ -13,12 +15,20 @@
existingFiles = [],
onUpload,
onDelete,
disabled = false
disabled = false,
acceptedTypes = DEFAULT_ACCEPTED_TYPES,
acceptAttr = DEFAULT_ACCEPT_ATTR,
hint = DEFAULT_HINT,
showDelete = true
}: {
existingFiles?: UploadedFile[];
onUpload: (file: File) => Promise<UploadedFile>;
onDelete: (fileId: string) => Promise<void>;
disabled?: boolean;
acceptedTypes?: string[];
acceptAttr?: string;
hint?: string;
showDelete?: boolean;
} = $props();
let files = $state<UploadedFile[]>(existingFiles);
@@ -43,8 +53,8 @@
}
function validateFile(file: File): string | null {
if (!ACCEPTED_TYPES.includes(file.type)) {
return `Type de fichier non accepté : ${file.type}. Types autorisés : PDF, JPEG, PNG.`;
if (!acceptedTypes.includes(file.type)) {
return `Type de fichier non accepté : ${file.type}.`;
}
if (file.size > MAX_FILE_SIZE) {
return `Le fichier dépasse la taille maximale de 10 Mo (${formatFileSize(file.size)}).`;
@@ -105,7 +115,7 @@
<span class="file-icon">{getFileIcon(file.mimeType)}</span>
<span class="file-name">{file.filename}</span>
<span class="file-size">{formatFileSize(file.fileSize)}</span>
{#if !disabled}
{#if !disabled && showDelete}
<button
type="button"
class="file-remove"
@@ -139,13 +149,13 @@
<input
bind:this={fileInput}
type="file"
accept=".pdf,.jpg,.jpeg,.png"
accept={acceptAttr}
onchange={handleFileSelect}
class="file-input-hidden"
aria-hidden="true"
tabindex="-1"
/>
<p class="upload-hint">PDF, JPEG ou PNG — 10 Mo max par fichier</p>
<p class="upload-hint">{hint}</p>
{/if}
</div>

View File

@@ -0,0 +1,569 @@
<script lang="ts">
import type {
HomeworkSubmission,
HomeworkAttachment,
StudentHomeworkDetail
} from '$lib/features/homework/api/studentHomework';
import {
fetchSubmission,
saveDraftSubmission,
submitHomework,
uploadSubmissionAttachment
} from '$lib/features/homework/api/studentHomework';
import RichTextEditor from '$lib/components/molecules/RichTextEditor/RichTextEditor.svelte';
import FileUpload from '$lib/components/molecules/FileUpload/FileUpload.svelte';
let {
detail,
onBack,
onSubmitted
}: {
detail: StudentHomeworkDetail;
onBack: () => void;
onSubmitted?: () => void;
} = $props();
let submission = $state<HomeworkSubmission | null>(null);
let responseHtml = $state<string>('');
let loading = $state(true);
let saving = $state(false);
let submitting = $state(false);
let error = $state<string | null>(null);
let successMessage = $state<string | null>(null);
let showConfirmDialog = $state(false);
let attachments = $state<HomeworkAttachment[]>([]);
let isSubmitted = $derived(
submission?.status === 'submitted' || submission?.status === 'late'
);
let statusLabel = $derived(() => {
if (!submission) return null;
switch (submission.status) {
case 'draft':
return { text: 'Brouillon', className: 'status-draft' };
case 'submitted':
return { text: 'Soumis', className: 'status-submitted' };
case 'late':
return { text: 'En retard', className: 'status-late' };
}
});
async function loadSubmission() {
loading = true;
error = null;
try {
submission = await fetchSubmission(detail.id);
if (submission) {
responseHtml = submission.responseHtml ?? '';
attachments = submission.attachments ?? [];
}
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur de chargement';
} finally {
loading = false;
}
}
async function handleSaveDraft() {
saving = true;
error = null;
successMessage = null;
try {
submission = await saveDraftSubmission(detail.id, responseHtml || null);
successMessage = 'Brouillon sauvegardé';
window.setTimeout(() => {
successMessage = null;
}, 3000);
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur lors de la sauvegarde';
} finally {
saving = false;
}
}
function handleSubmitClick() {
showConfirmDialog = true;
}
async function handleConfirmSubmit() {
showConfirmDialog = false;
submitting = true;
error = null;
try {
// Save draft first if needed
if (!submission) {
submission = await saveDraftSubmission(detail.id, responseHtml || null);
} else if (responseHtml !== (submission.responseHtml ?? '')) {
submission = await saveDraftSubmission(detail.id, responseHtml || null);
}
submission = await submitHomework(detail.id);
successMessage = 'Devoir rendu avec succès !';
onSubmitted?.();
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur lors de la soumission';
} finally {
submitting = false;
}
}
async function handleUploadAttachment(file: File): Promise<HomeworkAttachment> {
// Ensure a draft exists first
if (!submission) {
submission = await saveDraftSubmission(detail.id, responseHtml || null);
}
return uploadSubmissionAttachment(detail.id, file);
}
async function handleDeleteAttachment(_fileId: string): Promise<void> {
// Suppression non supportée — le bouton est masqué via showDelete={false}
}
function formatDueDate(dateStr: string): string {
const date = new Date(dateStr + 'T00:00:00');
return date.toLocaleDateString('fr-FR', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric'
});
}
$effect(() => {
void loadSubmission();
});
</script>
<div class="submission-form">
<button class="back-button" onclick={onBack}>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M19 12H5M12 19l-7-7 7-7" />
</svg>
Retour au devoir
</button>
<header class="form-header" style:border-left-color={detail.subjectColor ?? '#3b82f6'}>
<span class="subject-name" style:color={detail.subjectColor ?? '#3b82f6'}>
{detail.subjectName}
</span>
<h2 class="form-title">{detail.title}</h2>
<div class="form-meta">
<span class="due-date">Pour le {formatDueDate(detail.dueDate)}</span>
{#if statusLabel()}
<span class="status-badge {statusLabel()?.className}">{statusLabel()?.text}</span>
{/if}
</div>
</header>
{#if error}
<div class="error-banner" role="alert">{error}</div>
{/if}
{#if successMessage}
<div class="success-banner" role="status">{successMessage}</div>
{/if}
{#if loading}
<div class="loading-state">Chargement...</div>
{:else if isSubmitted}
<section class="submitted-view">
<div class="submitted-icon"></div>
<p class="submitted-message">
{#if submission?.status === 'late'}
Votre devoir a été rendu en retard le {submission?.submittedAt
? new Date(submission.submittedAt).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
: ''}.
{:else}
Votre devoir a été rendu le {submission?.submittedAt
? new Date(submission.submittedAt).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
: ''}.
{/if}
</p>
{#if submission?.responseHtml}
<section class="response-view">
<h3>Votre réponse</h3>
<div class="response-content">{@html submission.responseHtml}</div>
</section>
{/if}
{#if attachments.length > 0}
<section class="attachments-view">
<h3>Pièces jointes</h3>
<ul class="attachments-list">
{#each attachments as attachment}
<li class="attachment-item">
<span class="attachment-name">{attachment.filename}</span>
</li>
{/each}
</ul>
</section>
{/if}
</section>
{:else}
<section class="editor-section">
<h3>Votre réponse</h3>
<RichTextEditor
content={responseHtml}
onUpdate={(html) => {
responseHtml = html;
}}
placeholder="Rédigez votre réponse ici..."
/>
</section>
<section class="attachments-section">
<h3>Pièces jointes</h3>
<FileUpload
existingFiles={attachments}
onUpload={handleUploadAttachment}
onDelete={handleDeleteAttachment}
disabled={saving || submitting}
acceptedTypes={['application/pdf', 'image/jpeg', 'image/png', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document']}
acceptAttr=".pdf,.jpg,.jpeg,.png,.docx"
hint="PDF, JPEG, PNG ou DOCX — 10 Mo max par fichier"
showDelete={false}
/>
</section>
<div class="form-actions">
<button
class="btn-draft"
onclick={handleSaveDraft}
disabled={saving || submitting}
>
{saving ? 'Sauvegarde...' : 'Sauvegarder le brouillon'}
</button>
<button
class="btn-submit"
onclick={handleSubmitClick}
disabled={saving || submitting}
>
{submitting ? 'Soumission...' : 'Soumettre mon devoir'}
</button>
</div>
{/if}
</div>
{#if showConfirmDialog}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="dialog-overlay" role="presentation" onclick={() => (showConfirmDialog = false)}>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div class="dialog" role="alertdialog" aria-modal="true" onclick={(e) => e.stopPropagation()}>
<h3>Confirmer la soumission</h3>
<p>
Êtes-vous sûr de vouloir soumettre votre devoir ? Vous ne pourrez plus le modifier
après soumission.
</p>
<div class="dialog-actions">
<button class="btn-cancel" onclick={() => (showConfirmDialog = false)}>Annuler</button>
<button class="btn-confirm" onclick={handleConfirmSubmit}>Confirmer</button>
</div>
</div>
</div>
{/if}
<style>
.submission-form {
display: flex;
flex-direction: column;
gap: 1rem;
}
.back-button {
display: inline-flex;
align-items: center;
gap: 0.375rem;
background: none;
border: none;
color: #3b82f6;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
padding: 0.25rem 0;
align-self: flex-start;
}
.back-button:hover {
color: #2563eb;
}
.form-header {
border-left: 4px solid #3b82f6;
padding-left: 1rem;
}
.subject-name {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.form-title {
margin: 0.25rem 0 0.5rem;
font-size: 1.25rem;
font-weight: 600;
color: #1f2937;
}
.form-meta {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 0.875rem;
color: #6b7280;
}
.status-badge {
display: inline-block;
padding: 0.125rem 0.5rem;
border-radius: 1rem;
font-size: 0.75rem;
font-weight: 600;
}
.status-draft {
background: #e0e7ff;
color: #3730a3;
}
.status-submitted {
background: #dcfce7;
color: #166534;
}
.status-late {
background: #fee2e2;
color: #991b1b;
}
.error-banner {
padding: 0.5rem 0.75rem;
background: #fee2e2;
border-radius: 0.375rem;
color: #991b1b;
font-size: 0.8125rem;
}
.success-banner {
padding: 0.5rem 0.75rem;
background: #dcfce7;
border-radius: 0.375rem;
color: #166534;
font-size: 0.8125rem;
}
.loading-state {
text-align: center;
padding: 2rem;
color: #6b7280;
}
.editor-section h3,
.attachments-section h3 {
margin: 0 0 0.5rem;
font-size: 0.9375rem;
font-weight: 600;
color: #374151;
}
.form-actions {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
}
.btn-draft {
padding: 0.5rem 1rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
background: white;
color: #374151;
font-size: 0.875rem;
cursor: pointer;
}
.btn-draft:hover:not(:disabled) {
background: #f3f4f6;
}
.btn-submit {
padding: 0.5rem 1rem;
border: none;
border-radius: 0.375rem;
background: #3b82f6;
color: white;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
}
.btn-submit:hover:not(:disabled) {
background: #2563eb;
}
.btn-draft:disabled,
.btn-submit:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* Submitted view */
.submitted-view {
text-align: center;
padding: 1.5rem;
}
.submitted-icon {
width: 3rem;
height: 3rem;
margin: 0 auto 0.75rem;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background: #dcfce7;
color: #166534;
font-size: 1.5rem;
font-weight: 700;
}
.submitted-message {
color: #374151;
font-size: 0.9375rem;
}
.response-view,
.attachments-view {
text-align: left;
margin-top: 1rem;
}
.response-view h3,
.attachments-view h3 {
margin: 0 0 0.5rem;
font-size: 0.9375rem;
font-weight: 600;
color: #374151;
}
.response-content {
padding: 0.75rem;
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 0.375rem;
color: #4b5563;
line-height: 1.6;
}
.attachments-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.attachment-item {
padding: 0.5rem 0.75rem;
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 0.375rem;
font-size: 0.875rem;
}
.attachment-name {
color: #374151;
font-weight: 500;
}
/* Confirmation Dialog */
.dialog-overlay {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.5);
z-index: 100;
}
.dialog {
background: white;
border-radius: 0.5rem;
padding: 1.5rem;
max-width: 24rem;
width: 90%;
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
}
.dialog h3 {
margin: 0 0 0.75rem;
font-size: 1.125rem;
font-weight: 600;
color: #1f2937;
}
.dialog p {
margin: 0 0 1.25rem;
color: #4b5563;
font-size: 0.875rem;
line-height: 1.5;
}
.dialog-actions {
display: flex;
gap: 0.75rem;
justify-content: flex-end;
}
.btn-cancel {
padding: 0.5rem 1rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
background: white;
color: #374151;
font-size: 0.875rem;
cursor: pointer;
}
.btn-confirm {
padding: 0.5rem 1rem;
border: none;
border-radius: 0.375rem;
background: #3b82f6;
color: white;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
}
.btn-confirm:hover {
background: #2563eb;
}
</style>

View File

@@ -78,6 +78,13 @@
<span class="status-badge" class:done={isDone}>
{isDone ? 'Fait' : 'À faire'}
</span>
{#if homework.submissionStatus === 'submitted'}
<span class="submission-badge submission-submitted">Rendu</span>
{:else if homework.submissionStatus === 'late'}
<span class="submission-badge submission-late">Rendu en retard</span>
{:else if homework.submissionStatus === 'draft'}
<span class="submission-badge submission-draft">Brouillon</span>
{/if}
{#if homework.hasAttachments}
<span class="attachment-indicator" title="Pièce(s) jointe(s)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -178,4 +185,26 @@
display: flex;
align-items: center;
}
.submission-badge {
font-size: 0.75rem;
font-weight: 600;
border-radius: 999px;
padding: 0.125rem 0.5rem;
}
.submission-submitted {
color: #166534;
background: #dcfce7;
}
.submission-late {
color: #991b1b;
background: #fee2e2;
}
.submission-draft {
color: #3730a3;
background: #e0e7ff;
}
</style>

View File

@@ -6,10 +6,12 @@
let {
detail,
onBack,
onSubmit,
getAttachmentUrl = defaultGetAttachmentUrl
}: {
detail: StudentHomeworkDetail;
onBack: () => void;
onSubmit?: () => void;
getAttachmentUrl?: (homeworkId: string, attachmentId: string) => string;
} = $props();
@@ -45,7 +47,7 @@
}
link.click();
setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000);
window.setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000);
}
let downloadError = $state<string | null>(null);
@@ -97,6 +99,14 @@
</section>
{/if}
{#if onSubmit}
<div class="submit-section">
<button class="btn-submit-homework" onclick={onSubmit}>
Rendre mon devoir
</button>
</div>
{/if}
{#if detail.attachments.length > 0}
<section class="detail-attachments">
<h3>Pièces jointes</h3>
@@ -260,4 +270,27 @@
color: #9ca3af;
font-size: 0.75rem;
}
.submit-section {
padding-top: 0.5rem;
}
.btn-submit-homework {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.625rem 1.25rem;
border: none;
border-radius: 0.375rem;
background: #3b82f6;
color: white;
font-size: 0.9375rem;
font-weight: 500;
cursor: pointer;
transition: background-color 0.15s;
}
.btn-submit-homework:hover {
background: #2563eb;
}
</style>

View File

@@ -5,6 +5,7 @@
import { isOffline } from '$lib/features/schedule/stores/scheduleCache.svelte';
import HomeworkCard from './HomeworkCard.svelte';
import HomeworkDetail from './HomeworkDetail.svelte';
import HomeworkSubmissionForm from '$lib/components/organisms/HomeworkSubmission/HomeworkSubmissionForm.svelte';
import SkeletonList from '$lib/components/atoms/Skeleton/SkeletonList.svelte';
let homeworks = $state<StudentHomework[]>([]);
@@ -13,6 +14,7 @@
let error = $state<string | null>(null);
let selectedSubjectId = $state<string | null>(null);
let selectedDetail = $state<HomeworkDetailType | null>(null);
let showSubmissionForm = $state(false);
let detailLoading = $state(false);
let statuses = $derived(getHomeworkStatuses());
@@ -69,6 +71,24 @@
function handleBack() {
selectedDetail = null;
showSubmissionForm = false;
}
function handleOpenSubmissionForm() {
showSubmissionForm = true;
}
function handleSubmissionBack() {
showSubmissionForm = false;
}
function handleSubmitted() {
// Laisser le message de succès visible brièvement avant de revenir à la liste
window.setTimeout(() => {
showSubmissionForm = false;
selectedDetail = null;
void loadHomeworks();
}, 1500);
}
function handleToggleDone(homeworkId: string) {
@@ -81,8 +101,10 @@
});
</script>
{#if selectedDetail}
<HomeworkDetail detail={selectedDetail} onBack={handleBack} />
{#if selectedDetail && showSubmissionForm}
<HomeworkSubmissionForm detail={selectedDetail} onBack={handleSubmissionBack} onSubmitted={handleSubmitted} />
{:else if selectedDetail}
<HomeworkDetail detail={selectedDetail} onBack={handleBack} onSubmit={handleOpenSubmissionForm} />
{:else}
<div class="student-homework">
{#if isOffline()}