feat: Permettre à l'élève de rendre un devoir avec réponse texte et pièces jointes
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:
@@ -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>
|
||||
Reference in New Issue
Block a user