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

@@ -0,0 +1,497 @@
<script lang="ts">
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { authenticatedFetch } from '$lib/auth';
import type {
TeacherSubmission,
TeacherSubmissionDetail,
SubmissionStats
} from '$lib/features/homework/api/teacherSubmissions';
import {
fetchSubmissions,
fetchSubmissionDetail,
fetchSubmissionStats,
getSubmissionAttachmentUrl
} from '$lib/features/homework/api/teacherSubmissions';
let homeworkId = $derived(page.params.id ?? '');
let submissions = $state<TeacherSubmission[]>([]);
let stats = $state<SubmissionStats | null>(null);
let selectedDetail = $state<TeacherSubmissionDetail | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
let downloadError = $state<string | null>(null);
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
function formatFileSize(bytes: number): string {
if (bytes < 1024) return `${bytes} o`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} Ko`;
return `${(bytes / (1024 * 1024)).toFixed(1)} Mo`;
}
function statusLabel(status: string): { text: string; className: string } {
switch (status) {
case 'submitted':
return { text: 'Soumis', className: 'badge-submitted' };
case 'late':
return { text: 'En retard', className: 'badge-late' };
case 'draft':
return { text: 'Brouillon', className: 'badge-draft' };
case 'not_submitted':
return { text: 'Non rendu', className: 'badge-not-submitted' };
default:
return { text: status, className: '' };
}
}
async function loadData() {
loading = true;
error = null;
try {
const [subs, st] = await Promise.all([
fetchSubmissions(homeworkId),
fetchSubmissionStats(homeworkId)
]);
submissions = subs;
stats = st;
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur de chargement';
} finally {
loading = false;
}
}
async function handleViewDetail(submissionId: string) {
try {
selectedDetail = await fetchSubmissionDetail(homeworkId, submissionId);
} catch {
error = 'Erreur lors du chargement du détail.';
}
}
function handleBack() {
selectedDetail = null;
}
function shouldOpenInline(mimeType: string): boolean {
return mimeType === 'application/pdf' || mimeType.startsWith('image/') || mimeType.startsWith('text/');
}
async function downloadAttachment(submissionId: string, attachmentId: string, filename: string, mimeType: string) {
downloadError = null;
const url = getSubmissionAttachmentUrl(homeworkId, submissionId, attachmentId);
try {
const response = await authenticatedFetch(url);
if (!response.ok) {
downloadError = `Impossible de télécharger "${filename}".`;
return;
}
const blob = await response.blob();
const blobUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = blobUrl;
if (shouldOpenInline(mimeType)) {
link.target = '_blank';
link.rel = 'noopener noreferrer';
} else {
link.download = filename;
}
link.click();
window.setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000);
} catch {
downloadError = `Impossible de télécharger "${filename}".`;
}
}
$effect(() => {
void homeworkId;
void loadData();
});
</script>
<div class="submissions-page">
<button class="back-link" onclick={() => goto('/dashboard/teacher/homework')}>
<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 aux devoirs
</button>
{#if loading}
<div class="loading">Chargement des rendus...</div>
{:else if error}
<div class="error-message" role="alert">
<p>{error}</p>
<button onclick={() => void loadData()}>Réessayer</button>
</div>
{:else if selectedDetail}
<div class="detail-view">
<button class="back-link" onclick={handleBack}>
<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 à la liste
</button>
<header class="detail-header">
<h2>{selectedDetail.studentName}</h2>
<div class="detail-meta">
<span class="badge {statusLabel(selectedDetail.status).className}">
{statusLabel(selectedDetail.status).text}
</span>
{#if selectedDetail.submittedAt}
<span class="submitted-date">Soumis le {formatDate(selectedDetail.submittedAt)}</span>
{/if}
</div>
</header>
{#if selectedDetail.responseHtml}
<section class="detail-response">
<h3>Réponse</h3>
<div class="response-content">{@html selectedDetail.responseHtml}</div>
</section>
{:else}
<p class="no-response">Aucune réponse textuelle.</p>
{/if}
{#if selectedDetail.attachments.length > 0}
<section class="detail-attachments">
<h3>Pièces jointes</h3>
{#if downloadError}
<p class="download-error" role="alert">{downloadError}</p>
{/if}
<ul class="attachments-list">
{#each selectedDetail.attachments as attachment}
<li>
<button
class="attachment-item"
onclick={() => downloadAttachment(selectedDetail!.id, attachment.id, attachment.filename, attachment.mimeType)}
>
<span class="attachment-name">{attachment.filename}</span>
<span class="attachment-size">{formatFileSize(attachment.fileSize)}</span>
</button>
</li>
{/each}
</ul>
</section>
{/if}
</div>
{:else}
{#if stats}
<div class="stats-bar">
<div class="stat-item">
<span class="stat-value">{stats.submittedCount}</span>
<span class="stat-label">/ {stats.totalStudents} rendus</span>
</div>
</div>
{/if}
{#if submissions.length === 0}
<div class="empty-state">
<p>Aucun rendu pour ce devoir.</p>
</div>
{:else}
<div class="submissions-list">
<h3>Rendus ({submissions.length})</h3>
<table>
<thead>
<tr>
<th>Élève</th>
<th>Statut</th>
<th>Date de soumission</th>
<th></th>
</tr>
</thead>
<tbody>
{#each submissions as sub}
<tr>
<td>{sub.studentName}</td>
<td>
<span class="badge {statusLabel(sub.status).className}">
{statusLabel(sub.status).text}
</span>
</td>
<td>{sub.submittedAt ? formatDate(sub.submittedAt) : '—'}</td>
<td>
{#if sub.id}
<button class="btn-view" onclick={() => handleViewDetail(sub.id!)}>
Voir
</button>
{/if}
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
{/if}
</div>
<style>
.submissions-page {
max-width: 48rem;
margin: 0 auto;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 1rem;
}
.back-link {
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-link:hover {
color: #2563eb;
}
.loading {
text-align: center;
padding: 2rem;
color: #6b7280;
}
.error-message {
text-align: center;
padding: 1rem;
color: #ef4444;
}
.error-message button {
margin-top: 0.5rem;
padding: 0.375rem 0.75rem;
border: 1px solid #ef4444;
border-radius: 0.375rem;
background: white;
color: #ef4444;
cursor: pointer;
}
.stats-bar {
display: flex;
gap: 1rem;
padding: 0.75rem 1rem;
background: #f0f9ff;
border: 1px solid #bae6fd;
border-radius: 0.5rem;
}
.stat-item {
display: flex;
align-items: baseline;
gap: 0.375rem;
}
.stat-value {
font-size: 1.5rem;
font-weight: 700;
color: #0369a1;
}
.stat-label {
font-size: 0.875rem;
color: #6b7280;
}
.submissions-list h3,
.missing-students h3 {
margin: 0 0 0.5rem;
font-size: 0.9375rem;
font-weight: 600;
color: #374151;
}
table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
thead {
background: #f9fafb;
}
th {
text-align: left;
padding: 0.625rem 0.75rem;
font-weight: 600;
color: #6b7280;
border-bottom: 1px solid #e5e7eb;
}
td {
padding: 0.625rem 0.75rem;
border-bottom: 1px solid #f3f4f6;
color: #374151;
}
tr:hover {
background: #f9fafb;
}
.badge {
display: inline-block;
padding: 0.125rem 0.5rem;
border-radius: 1rem;
font-size: 0.75rem;
font-weight: 600;
}
.badge-submitted {
background: #dcfce7;
color: #166534;
}
.badge-late {
background: #fee2e2;
color: #991b1b;
}
.badge-draft {
background: #e0e7ff;
color: #3730a3;
}
.badge-not-submitted {
background: #f3f4f6;
color: #6b7280;
}
.btn-view {
padding: 0.25rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
background: white;
color: #3b82f6;
font-size: 0.8125rem;
cursor: pointer;
}
.btn-view:hover {
background: #eff6ff;
border-color: #3b82f6;
}
.empty-state {
text-align: center;
padding: 2rem;
color: #6b7280;
}
/* Detail view */
.detail-header h2 {
margin: 0 0 0.25rem;
font-size: 1.25rem;
font-weight: 600;
color: #1f2937;
}
.detail-meta {
display: flex;
align-items: center;
gap: 0.75rem;
font-size: 0.875rem;
color: #6b7280;
}
.submitted-date {
color: #6b7280;
}
.detail-response h3,
.detail-attachments 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;
}
.no-response {
color: #9ca3af;
font-style: italic;
}
.download-error {
margin: 0 0 0.5rem;
padding: 0.5rem 0.75rem;
background: #fee2e2;
border-radius: 0.375rem;
color: #991b1b;
font-size: 0.8125rem;
}
.attachments-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.attachment-item {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.625rem 0.75rem;
background: #f9fafb;
border: 1px solid #e5e7eb;
border-radius: 0.375rem;
cursor: pointer;
text-align: left;
font-size: 0.875rem;
}
.attachment-item:hover {
background: #f3f4f6;
border-color: #d1d5db;
}
.attachment-name {
flex: 1;
color: #3b82f6;
font-weight: 500;
}
.attachment-size {
color: #9ca3af;
font-size: 0.75rem;
}
</style>