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,394 @@
import { test, expect } from '@playwright/test';
import { execSync } from 'child_process';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173';
const urlMatch = baseUrl.match(/:(\d+)$/);
const PORT = urlMatch ? urlMatch[1] : '4173';
const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`;
const STUDENT_EMAIL = 'e2e-sub-student@example.com';
const STUDENT_PASSWORD = 'SubStudent123';
const TEACHER_EMAIL = 'e2e-sub-teacher@example.com';
const TEACHER_PASSWORD = 'SubTeacher123';
const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
const projectRoot = join(__dirname, '../..');
const composeFile = join(projectRoot, 'compose.yaml');
function runSql(sql: string) {
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "${sql}" 2>&1`,
{ encoding: 'utf-8' }
);
}
function clearCache() {
try {
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear paginated_queries.cache 2>&1`,
{ encoding: 'utf-8' }
);
} catch {
// Cache pool may not exist
}
}
function resolveDeterministicIds(): { schoolId: string; academicYearId: string } {
const output = execSync(
`docker compose -f "${composeFile}" exec -T php php -r '` +
`require "/app/vendor/autoload.php"; ` +
`$t="${TENANT_ID}"; $ns="6ba7b814-9dad-11d1-80b4-00c04fd430c8"; ` +
`echo Ramsey\\Uuid\\Uuid::uuid5($ns,"school-$t")->toString()."\\n"; ` +
`$m=(int)date("n"); $s=$m>=9?(int)date("Y"):(int)date("Y")-1; $e=$s+1; ` +
`echo Ramsey\\Uuid\\Uuid::uuid5($ns,"$t:$s-$e")->toString();` +
`' 2>&1`,
{ encoding: 'utf-8' }
).trim();
const [schoolId, academicYearId] = output.split('\n');
return { schoolId: schoolId!, academicYearId: academicYearId! };
}
function getNextWeekday(daysFromNow: number): string {
const date = new Date();
date.setDate(date.getDate() + daysFromNow);
const day = date.getDay();
if (day === 0) date.setDate(date.getDate() + 1);
if (day === 6) date.setDate(date.getDate() + 2);
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
function getPastDate(daysAgo: number): string {
const date = new Date();
date.setDate(date.getDate() - daysAgo);
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
async function loginAsStudent(page: import('@playwright/test').Page) {
await page.goto(`${ALPHA_URL}/login`);
await page.locator('#email').fill(STUDENT_EMAIL);
await page.locator('#password').fill(STUDENT_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}
async function loginAsTeacher(page: import('@playwright/test').Page) {
await page.goto(`${ALPHA_URL}/login`);
await page.locator('#email').fill(TEACHER_EMAIL);
await page.locator('#password').fill(TEACHER_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}
test.describe('Homework Submission (Story 5.10)', () => {
test.describe.configure({ mode: 'serial' });
const dueDate = getNextWeekday(7);
const pastDueDate = getPastDate(3);
test.beforeAll(async () => {
try {
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear cache.rate_limiter users.cache --env=dev 2>&1`,
{ encoding: 'utf-8' }
);
} catch {
// Cache pools may not exist
}
// Create student user
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${STUDENT_EMAIL} --password=${STUDENT_PASSWORD} --role=ROLE_ELEVE 2>&1`,
{ encoding: 'utf-8' }
);
// Create teacher user
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${TEACHER_EMAIL} --password=${TEACHER_PASSWORD} --role=ROLE_PROF 2>&1`,
{ encoding: 'utf-8' }
);
const { schoolId, academicYearId } = resolveDeterministicIds();
// Ensure class exists
try {
runSql(
`INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) ` +
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E-Sub-6A', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
} catch {
// May already exist
}
// Ensure subject exists
try {
runSql(
`INSERT INTO subjects (id, tenant_id, school_id, name, code, color, status, created_at, updated_at) ` +
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-Sub-Maths', 'E2ESUBMAT', '#3b82f6', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
} catch {
// May already exist
}
// Assign student to class
runSql(
`INSERT INTO class_assignments (id, tenant_id, user_id, school_class_id, academic_year_id, assigned_at, created_at, updated_at) ` +
`SELECT gen_random_uuid(), '${TENANT_ID}', u.id, c.id, '${academicYearId}', NOW(), NOW(), NOW() ` +
`FROM users u, school_classes c ` +
`WHERE u.email = '${STUDENT_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
`AND c.name = 'E2E-Sub-6A' AND c.tenant_id = '${TENANT_ID}' ` +
`ON CONFLICT DO NOTHING`
);
// Clean up submissions and homework
try {
runSql(
`DELETE FROM submission_attachments WHERE submission_id IN ` +
`(SELECT hs.id FROM homework_submissions hs JOIN homework h ON h.id = hs.homework_id ` +
`WHERE h.tenant_id = '${TENANT_ID}' AND h.class_id IN ` +
`(SELECT id FROM school_classes WHERE name = 'E2E-Sub-6A' AND tenant_id = '${TENANT_ID}'))`
);
runSql(
`DELETE FROM homework_submissions WHERE homework_id IN ` +
`(SELECT id FROM homework WHERE tenant_id = '${TENANT_ID}' AND class_id IN ` +
`(SELECT id FROM school_classes WHERE name = 'E2E-Sub-6A' AND tenant_id = '${TENANT_ID}'))`
);
runSql(
`DELETE FROM homework_attachments WHERE homework_id IN ` +
`(SELECT id FROM homework WHERE tenant_id = '${TENANT_ID}' AND class_id IN ` +
`(SELECT id FROM school_classes WHERE name = 'E2E-Sub-6A' AND tenant_id = '${TENANT_ID}'))`
);
runSql(
`DELETE FROM homework WHERE tenant_id = '${TENANT_ID}' AND class_id IN ` +
`(SELECT id FROM school_classes WHERE name = 'E2E-Sub-6A' AND tenant_id = '${TENANT_ID}')`
);
} catch {
// Tables may not exist
}
// Seed homework (future due date)
runSql(
`INSERT INTO homework (id, tenant_id, class_id, subject_id, teacher_id, title, description, due_date, status, created_at, updated_at) ` +
`SELECT gen_random_uuid(), '${TENANT_ID}', c.id, s.id, t.id, 'E2E Devoir à rendre', 'Rédigez un texte libre.', '${dueDate}', 'published', NOW(), NOW() ` +
`FROM school_classes c, ` +
`(SELECT id FROM subjects WHERE code = 'E2ESUBMAT' AND tenant_id = '${TENANT_ID}' LIMIT 1) s, ` +
`(SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}') t ` +
`WHERE c.name = 'E2E-Sub-6A' AND c.tenant_id = '${TENANT_ID}'`
);
// Seed homework (past due date for late submission test)
runSql(
`INSERT INTO homework (id, tenant_id, class_id, subject_id, teacher_id, title, description, due_date, status, created_at, updated_at) ` +
`SELECT gen_random_uuid(), '${TENANT_ID}', c.id, s.id, t.id, 'E2E Devoir en retard', 'Devoir déjà dû.', '${pastDueDate}', 'published', NOW(), NOW() ` +
`FROM school_classes c, ` +
`(SELECT id FROM subjects WHERE code = 'E2ESUBMAT' AND tenant_id = '${TENANT_ID}' LIMIT 1) s, ` +
`(SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}') t ` +
`WHERE c.name = 'E2E-Sub-6A' AND c.tenant_id = '${TENANT_ID}'`
);
clearCache();
});
// ======================================================================
// AC1 + AC3: Draft and Submission
// ======================================================================
test.describe('AC1+AC3: Write and submit homework', () => {
test('student sees "Rendre mon devoir" button in homework detail', async ({ page }) => {
await loginAsStudent(page);
await page.goto(`${ALPHA_URL}/dashboard/homework`);
await expect(page.locator('.homework-card')).toHaveCount(2, { timeout: 15000 });
// Click on the homework
await page.locator('.homework-card', { hasText: 'E2E Devoir à rendre' }).click();
await expect(page.locator('.homework-detail')).toBeVisible({ timeout: 10000 });
// Verify "Rendre mon devoir" button is visible
await expect(page.getByRole('button', { name: /rendre mon devoir/i })).toBeVisible();
});
test('student can save a draft', async ({ page }) => {
await loginAsStudent(page);
await page.goto(`${ALPHA_URL}/dashboard/homework`);
await page.locator('.homework-card', { hasText: 'E2E Devoir à rendre' }).click();
await expect(page.locator('.homework-detail')).toBeVisible({ timeout: 10000 });
// Click "Rendre mon devoir"
await page.getByRole('button', { name: /rendre mon devoir/i }).click();
await expect(page.locator('.submission-form')).toBeVisible({ timeout: 10000 });
// Wait for editor and type
const editor = page.locator('.ProseMirror');
await expect(editor).toBeVisible({ timeout: 10000 });
await editor.click();
await page.keyboard.type('Mon brouillon de reponse');
// Save draft
await page.getByRole('button', { name: /sauvegarder le brouillon/i }).click();
await expect(page.locator('.success-banner')).toContainText('Brouillon sauvegardé', {
timeout: 10000
});
});
test('student can upload a file attachment', async ({ page }) => {
await loginAsStudent(page);
await page.goto(`${ALPHA_URL}/dashboard/homework`);
await page.locator('.homework-card', { hasText: 'E2E Devoir à rendre' }).click();
await expect(page.locator('.homework-detail')).toBeVisible({ timeout: 10000 });
await page.getByRole('button', { name: /rendre mon devoir/i }).click();
await expect(page.locator('.submission-form')).toBeVisible({ timeout: 10000 });
// Upload a PDF file
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles({
name: 'devoir-eleve.pdf',
mimeType: 'application/pdf',
buffer: Buffer.from('Fake PDF content for E2E test')
});
// Wait for file to appear in the list
await expect(page.locator('.file-item')).toBeVisible({ timeout: 10000 });
await expect(page.getByText('devoir-eleve.pdf')).toBeVisible();
});
test('student can submit homework with confirmation', async ({ page }) => {
await loginAsStudent(page);
await page.goto(`${ALPHA_URL}/dashboard/homework`);
await page.locator('.homework-card', { hasText: 'E2E Devoir à rendre' }).click();
await expect(page.locator('.homework-detail')).toBeVisible({ timeout: 10000 });
await page.getByRole('button', { name: /rendre mon devoir/i }).click();
await expect(page.locator('.submission-form')).toBeVisible({ timeout: 10000 });
// Click submit
await page.getByRole('button', { name: /soumettre mon devoir/i }).click();
// Confirmation dialog should appear
await expect(page.locator('[role="alertdialog"]')).toBeVisible({ timeout: 5000 });
await expect(page.locator('[role="alertdialog"]')).toContainText(
'Confirmer la soumission'
);
// Confirm submission
await page.getByRole('button', { name: /confirmer/i }).click();
// Should show success
await expect(page.locator('.success-banner')).toContainText('rendu avec succès', {
timeout: 10000
});
});
});
// ======================================================================
// AC4: Status in homework list
// ======================================================================
test.describe('AC4: Submission status in list', () => {
test('student sees submitted status in homework list', async ({ page }) => {
await loginAsStudent(page);
await page.goto(`${ALPHA_URL}/dashboard/homework`);
await expect(page.locator('.homework-card')).toHaveCount(2, { timeout: 15000 });
const card = page.locator('.homework-card', { hasText: 'E2E Devoir à rendre' });
await expect(card.locator('.submission-submitted')).toContainText('Rendu');
});
});
// ======================================================================
// AC4: Late submission
// ======================================================================
test.describe('AC4: Late submission', () => {
test('late submission shows en retard status', async ({ page }) => {
await loginAsStudent(page);
await page.goto(`${ALPHA_URL}/dashboard/homework`);
await expect(page.locator('.homework-card')).toHaveCount(2, { timeout: 15000 });
await page.locator('.homework-card', { hasText: 'E2E Devoir en retard' }).click();
await expect(page.locator('.homework-detail')).toBeVisible({ timeout: 10000 });
await page.getByRole('button', { name: /rendre mon devoir/i }).click();
await expect(page.locator('.submission-form')).toBeVisible({ timeout: 10000 });
// Wait for editor to be ready then type
const editor = page.locator('.ProseMirror');
await expect(editor).toBeVisible({ timeout: 10000 });
await editor.click();
await page.keyboard.type('Rendu en retard');
// Save draft first
await page.getByRole('button', { name: /sauvegarder le brouillon/i }).click();
await expect(page.locator('.success-banner')).toBeVisible({ timeout: 10000 });
// Wait for success to appear then submit
await page.getByRole('button', { name: /soumettre mon devoir/i }).click();
await expect(page.locator('[role="alertdialog"]')).toBeVisible({ timeout: 5000 });
await page.getByRole('button', { name: /confirmer/i }).click();
await expect(page.locator('.success-banner')).toContainText('rendu', {
timeout: 15000
});
// Go back to list and check status
await page.goto(`${ALPHA_URL}/dashboard/homework`);
await page.waitForLoadState('networkidle');
await expect(page.locator('.homework-card')).toHaveCount(2, { timeout: 15000 });
const card = page.locator('.homework-card', { hasText: 'E2E Devoir en retard' });
await expect(card.locator('.submission-late')).toContainText('retard', {
timeout: 15000
});
});
});
// ======================================================================
// AC5 + AC6: Teacher views submissions and stats
// ======================================================================
test.describe('AC5+AC6: Teacher submission views', () => {
test('teacher can view submissions list and stats', async ({ page }) => {
await loginAsTeacher(page);
// Get the homework ID from the database
const homeworkIdOutput = execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "SELECT id FROM homework WHERE title = 'E2E Devoir à rendre' AND tenant_id = '${TENANT_ID}' LIMIT 1" 2>&1`,
{ encoding: 'utf-8' }
);
const idMatch = homeworkIdOutput.match(
/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/
);
if (!idMatch) {
throw new Error('Could not find homework ID');
}
const homeworkId = idMatch[0];
// Navigate to submissions page
await page.goto(
`${ALPHA_URL}/dashboard/teacher/homework/${homeworkId}/submissions`
);
// Should see stats
await expect(page.locator('.stat-value')).toContainText('1', { timeout: 15000 });
// Should see the submission in the table
await expect(page.locator('tbody tr')).toHaveCount(1, { timeout: 10000 });
// Should see "Voir" button
await expect(page.getByRole('button', { name: 'Voir', exact: true })).toBeVisible();
// Click to view detail
await page.getByRole('button', { name: 'Voir', exact: true }).click();
await expect(page.locator('.detail-header')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.response-content')).toBeVisible();
});
});
});

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>