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()}

View File

@@ -13,6 +13,7 @@ export interface StudentHomework {
dueDate: string;
createdAt: string;
hasAttachments: boolean;
submissionStatus: 'draft' | 'submitted' | 'late' | null;
}
export interface HomeworkAttachment {
@@ -36,6 +37,18 @@ export interface StudentHomeworkDetail {
attachments: HomeworkAttachment[];
}
export interface HomeworkSubmission {
id: string;
homeworkId: string;
studentId: string;
responseHtml: string | null;
status: 'draft' | 'submitted' | 'late';
submittedAt: string | null;
createdAt: string;
updatedAt: string;
attachments?: HomeworkAttachment[];
}
/**
* Récupère la liste des devoirs pour l'élève connecté.
*/
@@ -74,3 +87,87 @@ export function getAttachmentUrl(homeworkId: string, attachmentId: string): stri
const apiUrl = getApiBaseUrl();
return `${apiUrl}/me/homework/${homeworkId}/attachments/${attachmentId}`;
}
/**
* Récupère le rendu de l'élève pour un devoir (ou null si aucun brouillon).
*/
export async function fetchSubmission(homeworkId: string): Promise<HomeworkSubmission | null> {
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(`${apiUrl}/me/homework/${homeworkId}/submission`);
if (!response.ok) {
throw new Error(`Erreur lors du chargement du rendu (${response.status})`);
}
const json = await response.json();
return json.data ?? null;
}
/**
* Sauvegarde un brouillon de rendu.
*/
export async function saveDraftSubmission(
homeworkId: string,
responseHtml: string | null
): Promise<HomeworkSubmission> {
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(`${apiUrl}/me/homework/${homeworkId}/submission`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ responseHtml })
});
if (!response.ok) {
const errorData = await response.json().catch(() => null);
throw new Error(errorData?.detail ?? `Erreur lors de la sauvegarde (${response.status})`);
}
const json = await response.json();
return json.data;
}
/**
* Soumet définitivement le rendu.
*/
export async function submitHomework(homeworkId: string): Promise<HomeworkSubmission> {
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(`${apiUrl}/me/homework/${homeworkId}/submission/submit`, {
method: 'POST'
});
if (!response.ok) {
const errorData = await response.json().catch(() => null);
throw new Error(errorData?.detail ?? `Erreur lors de la soumission (${response.status})`);
}
const json = await response.json();
return json.data;
}
/**
* Upload une pièce jointe au rendu de l'élève.
*/
export async function uploadSubmissionAttachment(
homeworkId: string,
file: File
): Promise<HomeworkAttachment> {
const apiUrl = getApiBaseUrl();
const formData = new FormData();
formData.append('file', file);
const response = await authenticatedFetch(
`${apiUrl}/me/homework/${homeworkId}/submission/attachments`,
{
method: 'POST',
body: formData
}
);
if (!response.ok) {
const errorData = await response.json().catch(() => null);
throw new Error(errorData?.detail ?? "Erreur lors de l'envoi du fichier.");
}
const json = await response.json();
return json.data;
}

View File

@@ -0,0 +1,97 @@
import { getApiBaseUrl } from '$lib/api';
import { authenticatedFetch } from '$lib/auth';
export interface TeacherSubmission {
id: string | null;
studentId: string;
studentName: string;
status: 'draft' | 'submitted' | 'late' | 'not_submitted';
submittedAt: string | null;
createdAt: string | null;
}
export interface TeacherSubmissionDetail {
id: string;
studentId: string;
studentName: string;
responseHtml: string | null;
status: 'draft' | 'submitted' | 'late';
submittedAt: string | null;
createdAt: string;
attachments: {
id: string;
filename: string;
fileSize: number;
mimeType: string;
}[];
}
export interface SubmissionStats {
totalStudents: number;
submittedCount: number;
missingStudents: { id: string; name: string }[];
}
/**
* Récupère la liste des rendus pour un devoir.
*/
export async function fetchSubmissions(homeworkId: string): Promise<TeacherSubmission[]> {
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(`${apiUrl}/homework/${homeworkId}/submissions`);
if (!response.ok) {
throw new Error(`Erreur lors du chargement des rendus (${response.status})`);
}
const json = await response.json();
return json.data ?? [];
}
/**
* Récupère le détail d'un rendu.
*/
export async function fetchSubmissionDetail(
homeworkId: string,
submissionId: string
): Promise<TeacherSubmissionDetail> {
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(
`${apiUrl}/homework/${homeworkId}/submissions/${submissionId}`
);
if (!response.ok) {
throw new Error(`Erreur lors du chargement du rendu (${response.status})`);
}
const json = await response.json();
return json.data;
}
/**
* Récupère les statistiques de rendus pour un devoir.
*/
export async function fetchSubmissionStats(homeworkId: string): Promise<SubmissionStats> {
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(
`${apiUrl}/homework/${homeworkId}/submissions/stats`
);
if (!response.ok) {
throw new Error(`Erreur lors du chargement des statistiques (${response.status})`);
}
const json = await response.json();
return json.data;
}
/**
* Retourne l'URL de téléchargement d'une pièce jointe de rendu.
*/
export function getSubmissionAttachmentUrl(
homeworkId: string,
submissionId: string,
attachmentId: string
): string {
const apiUrl = getApiBaseUrl();
return `${apiUrl}/homework/${homeworkId}/submissions/${submissionId}/attachments/${attachmentId}`;
}

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>