feat: Permettre à l'élève de consulter ses devoirs

L'élève n'avait aucun moyen de voir les devoirs assignés à sa classe.
Cette fonctionnalité ajoute la consultation complète : liste triée par
échéance, détail avec pièces jointes, filtrage par matière, et marquage
personnel « fait » en localStorage.

Le dashboard élève affiche désormais les devoirs à venir avec ouverture
du détail en modale, et un lien vers la page complète. L'accès API est
sécurisé par vérification de la classe de l'élève (pas d'IDOR) et
validation du chemin des pièces jointes (pas de path traversal).
This commit is contained in:
2026-03-22 17:01:32 +01:00
parent 14c7849179
commit 2e2328c6ca
20 changed files with 2442 additions and 12 deletions

View File

@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Query\GetStudentHomework;
final readonly class AttachmentDto
{
public function __construct(
public string $id,
public string $filename,
public int $fileSize,
public string $mimeType,
) {
}
}

View File

@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Query\GetStudentHomework;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Scolarite\Application\Port\ScheduleDisplayReader;
use App\Scolarite\Application\Port\StudentClassReader;
use App\Scolarite\Domain\Model\Homework\Homework;
use App\Scolarite\Domain\Model\Homework\HomeworkId;
use App\Scolarite\Domain\Repository\HomeworkAttachmentRepository;
use App\Scolarite\Domain\Repository\HomeworkRepository;
use App\Shared\Domain\Tenant\TenantId;
use function array_filter;
use function array_map;
use function array_unique;
use function array_values;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use function usort;
#[AsMessageHandler(bus: 'query.bus')]
final readonly class GetStudentHomeworkHandler
{
public function __construct(
private StudentClassReader $studentClassReader,
private HomeworkRepository $homeworkRepository,
private HomeworkAttachmentRepository $attachmentRepository,
private ScheduleDisplayReader $displayReader,
) {
}
/** @return array<StudentHomeworkDto> */
public function __invoke(GetStudentHomeworkQuery $query): array
{
$tenantId = TenantId::fromString($query->tenantId);
$classId = $this->studentClassReader->currentClassId($query->studentId, $tenantId);
if ($classId === null) {
return [];
}
$homeworks = $this->homeworkRepository->findByClass(ClassId::fromString($classId), $tenantId);
if ($query->subjectId !== null) {
$filterSubjectId = $query->subjectId;
$homeworks = array_values(array_filter(
$homeworks,
static fn (Homework $h): bool => (string) $h->subjectId === $filterSubjectId,
));
}
usort($homeworks, static fn (Homework $a, Homework $b): int => $a->dueDate <=> $b->dueDate);
return $this->enrichHomeworks($homeworks, $query->tenantId);
}
/**
* @param array<Homework> $homeworks
*
* @return array<StudentHomeworkDto>
*/
private function enrichHomeworks(array $homeworks, string $tenantId): array
{
if ($homeworks === []) {
return [];
}
$subjectIds = array_values(array_unique(
array_map(static fn (Homework $h): string => (string) $h->subjectId, $homeworks),
));
$teacherIds = array_values(array_unique(
array_map(static fn (Homework $h): string => (string) $h->teacherId, $homeworks),
));
$subjects = $this->displayReader->subjectDisplay($tenantId, ...$subjectIds);
$teacherNames = $this->displayReader->teacherNames($tenantId, ...$teacherIds);
$homeworkIds = array_map(static fn (Homework $h): HomeworkId => $h->id, $homeworks);
$attachmentMap = $this->attachmentRepository->hasAttachments(...$homeworkIds);
return array_map(
static fn (Homework $h): StudentHomeworkDto => StudentHomeworkDto::fromDomain(
$h,
$subjects[(string) $h->subjectId]['name'] ?? '',
$subjects[(string) $h->subjectId]['color'] ?? null,
$teacherNames[(string) $h->teacherId] ?? '',
$attachmentMap[(string) $h->id] ?? false,
),
$homeworks,
);
}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Query\GetStudentHomework;
final readonly class GetStudentHomeworkQuery
{
public function __construct(
public string $studentId,
public string $tenantId,
public ?string $subjectId = null,
) {
}
}

View File

@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Query\GetStudentHomework;
use App\Scolarite\Domain\Model\Homework\Homework;
use App\Scolarite\Domain\Model\Homework\HomeworkAttachment;
use function array_map;
final readonly class StudentHomeworkDetailDto
{
/**
* @param array<AttachmentDto> $attachments
*/
public function __construct(
public string $id,
public string $subjectId,
public string $subjectName,
public ?string $subjectColor,
public string $teacherId,
public string $teacherName,
public string $title,
public ?string $description,
public string $dueDate,
public string $createdAt,
public array $attachments,
) {
}
/**
* @param array<HomeworkAttachment> $attachments
*/
public static function fromDomain(
Homework $homework,
string $subjectName,
?string $subjectColor,
string $teacherName,
array $attachments,
): self {
return new self(
id: (string) $homework->id,
subjectId: (string) $homework->subjectId,
subjectName: $subjectName,
subjectColor: $subjectColor,
teacherId: (string) $homework->teacherId,
teacherName: $teacherName,
title: $homework->title,
description: $homework->description,
dueDate: $homework->dueDate->format('Y-m-d'),
createdAt: $homework->createdAt->format('Y-m-d\TH:i:sP'),
attachments: array_map(
static fn (HomeworkAttachment $a): AttachmentDto => new AttachmentDto(
id: (string) $a->id,
filename: $a->filename,
fileSize: $a->fileSize,
mimeType: $a->mimeType,
),
$attachments,
),
);
}
}

View File

@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Query\GetStudentHomework;
use App\Scolarite\Domain\Model\Homework\Homework;
final readonly class StudentHomeworkDto
{
public function __construct(
public string $id,
public string $subjectId,
public string $subjectName,
public ?string $subjectColor,
public string $teacherId,
public string $teacherName,
public string $title,
public ?string $description,
public string $dueDate,
public string $createdAt,
public bool $hasAttachments,
) {
}
public static function fromDomain(
Homework $homework,
string $subjectName,
?string $subjectColor,
string $teacherName,
bool $hasAttachments,
): self {
return new self(
id: (string) $homework->id,
subjectId: (string) $homework->subjectId,
subjectName: $subjectName,
subjectColor: $subjectColor,
teacherId: (string) $homework->teacherId,
teacherName: $teacherName,
title: $homework->title,
description: $homework->description,
dueDate: $homework->dueDate->format('Y-m-d'),
createdAt: $homework->createdAt->format('Y-m-d\TH:i:sP'),
hasAttachments: $hasAttachments,
);
}
}

View File

@@ -12,5 +12,10 @@ interface HomeworkAttachmentRepository
/** @return array<HomeworkAttachment> */ /** @return array<HomeworkAttachment> */
public function findByHomeworkId(HomeworkId $homeworkId): array; public function findByHomeworkId(HomeworkId $homeworkId): array;
/**
* @return array<string, bool> Map homeworkId => hasAttachments
*/
public function hasAttachments(HomeworkId ...$homeworkIds): array;
public function save(HomeworkId $homeworkId, HomeworkAttachment $attachment): void; public function save(HomeworkId $homeworkId, HomeworkAttachment $attachment): void;
} }

View File

@@ -0,0 +1,208 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Api\Controller;
use App\Administration\Infrastructure\Security\SecurityUser;
use App\Scolarite\Application\Port\ScheduleDisplayReader;
use App\Scolarite\Application\Port\StudentClassReader;
use App\Scolarite\Application\Query\GetStudentHomework\GetStudentHomeworkHandler;
use App\Scolarite\Application\Query\GetStudentHomework\GetStudentHomeworkQuery;
use App\Scolarite\Application\Query\GetStudentHomework\StudentHomeworkDetailDto;
use App\Scolarite\Application\Query\GetStudentHomework\StudentHomeworkDto;
use App\Scolarite\Domain\Model\Homework\Homework;
use App\Scolarite\Domain\Model\Homework\HomeworkId;
use App\Scolarite\Domain\Repository\HomeworkAttachmentRepository;
use App\Scolarite\Domain\Repository\HomeworkRepository;
use App\Scolarite\Infrastructure\Security\HomeworkStudentVoter;
use App\Shared\Domain\Tenant\TenantId;
use function array_map;
use function is_string;
use function realpath;
use function str_starts_with;
use Symfony\Bundle\SecurityBundle\Security;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\ResponseHeaderBag;
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
#[IsGranted(HomeworkStudentVoter::VIEW)]
final readonly class StudentHomeworkController
{
public function __construct(
private Security $security,
private GetStudentHomeworkHandler $handler,
private HomeworkRepository $homeworkRepository,
private HomeworkAttachmentRepository $attachmentRepository,
private ScheduleDisplayReader $displayReader,
private StudentClassReader $studentClassReader,
#[Autowire('%kernel.project_dir%/var/uploads')]
private string $uploadsDir,
) {
}
#[Route('/api/me/homework', name: 'api_student_homework_list', methods: ['GET'])]
public function list(Request $request): JsonResponse
{
$user = $this->getSecurityUser();
$subjectId = $request->query->get('subjectId');
$result = ($this->handler)(new GetStudentHomeworkQuery(
studentId: $user->userId(),
tenantId: $user->tenantId(),
subjectId: is_string($subjectId) && $subjectId !== '' ? $subjectId : null,
));
return new JsonResponse([
'data' => array_map($this->serializeListItem(...), $result),
]);
}
#[Route('/api/me/homework/{id}', name: 'api_student_homework_detail', methods: ['GET'])]
public function detail(string $id): JsonResponse
{
$user = $this->getSecurityUser();
$tenantId = TenantId::fromString($user->tenantId());
$homework = $this->homeworkRepository->findById(HomeworkId::fromString($id), $tenantId);
if ($homework === null) {
throw new NotFoundHttpException('Devoir non trouvé.');
}
$this->assertHomeworkBelongsToStudentClass($user, $homework);
$attachments = $this->attachmentRepository->findByHomeworkId($homework->id);
$subjects = $this->displayReader->subjectDisplay($user->tenantId(), (string) $homework->subjectId);
$teacherNames = $this->displayReader->teacherNames($user->tenantId(), (string) $homework->teacherId);
$detail = StudentHomeworkDetailDto::fromDomain(
$homework,
$subjects[(string) $homework->subjectId]['name'] ?? '',
$subjects[(string) $homework->subjectId]['color'] ?? null,
$teacherNames[(string) $homework->teacherId] ?? '',
$attachments,
);
return new JsonResponse(['data' => $this->serializeDetail($detail)]);
}
#[Route('/api/me/homework/{homeworkId}/attachments/{attachmentId}', name: 'api_student_homework_attachment', methods: ['GET'])]
public function downloadAttachment(string $homeworkId, string $attachmentId): BinaryFileResponse
{
$user = $this->getSecurityUser();
$tenantId = TenantId::fromString($user->tenantId());
$homework = $this->homeworkRepository->findById(HomeworkId::fromString($homeworkId), $tenantId);
if ($homework === null) {
throw new NotFoundHttpException('Devoir non trouvé.');
}
$this->assertHomeworkBelongsToStudentClass($user, $homework);
$attachments = $this->attachmentRepository->findByHomeworkId($homework->id);
foreach ($attachments as $attachment) {
if ((string) $attachment->id === $attachmentId) {
$realPath = realpath($attachment->filePath);
$realUploadsDir = realpath($this->uploadsDir);
if ($realPath === false || $realUploadsDir === false || !str_starts_with($realPath, $realUploadsDir)) {
throw new NotFoundHttpException('Pièce jointe non trouvée.');
}
$response = new BinaryFileResponse($realPath);
$response->setContentDisposition(
ResponseHeaderBag::DISPOSITION_INLINE,
$attachment->filename,
);
return $response;
}
}
throw new NotFoundHttpException('Pièce jointe non trouvée.');
}
private function assertHomeworkBelongsToStudentClass(SecurityUser $user, Homework $homework): void
{
$classId = $this->studentClassReader->currentClassId(
$user->userId(),
TenantId::fromString($user->tenantId()),
);
if ($classId === null || (string) $homework->classId !== $classId) {
throw new NotFoundHttpException('Devoir non trouvé.');
}
}
private function getSecurityUser(): SecurityUser
{
$user = $this->security->getUser();
if (!$user instanceof SecurityUser) {
throw new AccessDeniedHttpException('Authentification requise.');
}
return $user;
}
/**
* @return array<string, mixed>
*/
private function serializeListItem(StudentHomeworkDto $dto): array
{
return [
'id' => $dto->id,
'subjectId' => $dto->subjectId,
'subjectName' => $dto->subjectName,
'subjectColor' => $dto->subjectColor,
'teacherId' => $dto->teacherId,
'teacherName' => $dto->teacherName,
'title' => $dto->title,
'description' => $dto->description,
'dueDate' => $dto->dueDate,
'createdAt' => $dto->createdAt,
'hasAttachments' => $dto->hasAttachments,
];
}
/**
* @return array<string, mixed>
*/
private function serializeDetail(StudentHomeworkDetailDto $dto): array
{
return [
'id' => $dto->id,
'subjectId' => $dto->subjectId,
'subjectName' => $dto->subjectName,
'subjectColor' => $dto->subjectColor,
'teacherId' => $dto->teacherId,
'teacherName' => $dto->teacherName,
'title' => $dto->title,
'description' => $dto->description,
'dueDate' => $dto->dueDate,
'createdAt' => $dto->createdAt,
'attachments' => array_map(
static fn ($a): array => [
'id' => $a->id,
'filename' => $a->filename,
'fileSize' => $a->fileSize,
'mimeType' => $a->mimeType,
],
$dto->attachments,
),
];
}
}

View File

@@ -9,9 +9,11 @@ use App\Scolarite\Domain\Model\Homework\HomeworkAttachmentId;
use App\Scolarite\Domain\Model\Homework\HomeworkId; use App\Scolarite\Domain\Model\Homework\HomeworkId;
use App\Scolarite\Domain\Repository\HomeworkAttachmentRepository; use App\Scolarite\Domain\Repository\HomeworkAttachmentRepository;
use function array_fill_keys;
use function array_map; use function array_map;
use DateTimeImmutable; use DateTimeImmutable;
use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\Connection; use Doctrine\DBAL\Connection;
use Override; use Override;
@@ -33,6 +35,33 @@ final readonly class DoctrineHomeworkAttachmentRepository implements HomeworkAtt
return array_map($this->hydrate(...), $rows); return array_map($this->hydrate(...), $rows);
} }
#[Override]
public function hasAttachments(HomeworkId ...$homeworkIds): array
{
if ($homeworkIds === []) {
return [];
}
$ids = array_map(static fn (HomeworkId $id): string => (string) $id, $homeworkIds);
/** @var array<array{homework_id: string}> $rows */
$rows = $this->connection->fetchAllAssociative(
'SELECT DISTINCT homework_id FROM homework_attachments WHERE homework_id IN (:ids)',
['ids' => $ids],
['ids' => ArrayParameterType::STRING],
);
$result = array_fill_keys($ids, false);
foreach ($rows as $row) {
/** @var string $hwId */
$hwId = $row['homework_id'];
$result[$hwId] = true;
}
return $result;
}
#[Override] #[Override]
public function save(HomeworkId $homeworkId, HomeworkAttachment $attachment): void public function save(HomeworkId $homeworkId, HomeworkAttachment $attachment): void
{ {

View File

@@ -7,6 +7,10 @@ namespace App\Scolarite\Infrastructure\Persistence\InMemory;
use App\Scolarite\Domain\Model\Homework\HomeworkAttachment; use App\Scolarite\Domain\Model\Homework\HomeworkAttachment;
use App\Scolarite\Domain\Model\Homework\HomeworkId; use App\Scolarite\Domain\Model\Homework\HomeworkId;
use App\Scolarite\Domain\Repository\HomeworkAttachmentRepository; use App\Scolarite\Domain\Repository\HomeworkAttachmentRepository;
use function array_fill_keys;
use function array_map;
use Override; use Override;
final class InMemoryHomeworkAttachmentRepository implements HomeworkAttachmentRepository final class InMemoryHomeworkAttachmentRepository implements HomeworkAttachmentRepository
@@ -20,6 +24,19 @@ final class InMemoryHomeworkAttachmentRepository implements HomeworkAttachmentRe
return $this->byHomeworkId[(string) $homeworkId] ?? []; return $this->byHomeworkId[(string) $homeworkId] ?? [];
} }
#[Override]
public function hasAttachments(HomeworkId ...$homeworkIds): array
{
$ids = array_map(static fn (HomeworkId $id): string => (string) $id, $homeworkIds);
$result = array_fill_keys($ids, false);
foreach ($ids as $id) {
$result[$id] = isset($this->byHomeworkId[$id]) && $this->byHomeworkId[$id] !== [];
}
return $result;
}
#[Override] #[Override]
public function save(HomeworkId $homeworkId, HomeworkAttachment $attachment): void public function save(HomeworkId $homeworkId, HomeworkAttachment $attachment): void
{ {

View File

@@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Security;
use App\Administration\Domain\Model\User\Role;
use App\Administration\Infrastructure\Security\SecurityUser;
use function in_array;
use Override;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Vote;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;
/**
* Voter pour la consultation des devoirs par l'élève.
*
* @extends Voter<string, null>
*/
final class HomeworkStudentVoter extends Voter
{
public const string VIEW = 'HOMEWORK_STUDENT_VIEW';
#[Override]
protected function supports(string $attribute, mixed $subject): bool
{
return $attribute === self::VIEW;
}
#[Override]
protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
{
$user = $token->getUser();
if (!$user instanceof SecurityUser) {
return false;
}
return in_array(Role::ELEVE->value, $user->getRoles(), true);
}
}

View File

@@ -0,0 +1,259 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Scolarite\Application\Query\GetStudentHomework;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Administration\Domain\Model\Subject\SubjectId;
use App\Administration\Domain\Model\User\UserId;
use App\Scolarite\Application\Port\ScheduleDisplayReader;
use App\Scolarite\Application\Port\StudentClassReader;
use App\Scolarite\Application\Query\GetStudentHomework\GetStudentHomeworkHandler;
use App\Scolarite\Application\Query\GetStudentHomework\GetStudentHomeworkQuery;
use App\Scolarite\Domain\Model\Homework\Homework;
use App\Scolarite\Domain\Model\Homework\HomeworkAttachment;
use App\Scolarite\Domain\Model\Homework\HomeworkAttachmentId;
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryHomeworkAttachmentRepository;
use App\Scolarite\Infrastructure\Persistence\InMemory\InMemoryHomeworkRepository;
use App\Shared\Domain\Tenant\TenantId;
use DateTimeImmutable;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
final class GetStudentHomeworkHandlerTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string STUDENT_ID = '550e8400-e29b-41d4-a716-446655440050';
private const string CLASS_ID = '550e8400-e29b-41d4-a716-446655440020';
private const string SUBJECT_MATH_ID = '550e8400-e29b-41d4-a716-446655440030';
private const string SUBJECT_FRENCH_ID = '550e8400-e29b-41d4-a716-446655440031';
private const string TEACHER_ID = '550e8400-e29b-41d4-a716-446655440010';
private InMemoryHomeworkRepository $homeworkRepository;
private InMemoryHomeworkAttachmentRepository $attachmentRepository;
protected function setUp(): void
{
$this->homeworkRepository = new InMemoryHomeworkRepository();
$this->attachmentRepository = new InMemoryHomeworkAttachmentRepository();
}
#[Test]
public function itReturnsEmptyWhenStudentHasNoClass(): void
{
$handler = $this->createHandler(classId: null);
$result = $handler(new GetStudentHomeworkQuery(
studentId: self::STUDENT_ID,
tenantId: self::TENANT_ID,
));
self::assertSame([], $result);
}
#[Test]
public function itReturnsHomeworkForStudentClass(): void
{
$this->givenHomework(title: 'Exercices chapitre 5', dueDate: '2026-04-15');
$handler = $this->createHandler();
$result = $handler(new GetStudentHomeworkQuery(
studentId: self::STUDENT_ID,
tenantId: self::TENANT_ID,
));
self::assertCount(1, $result);
self::assertSame('Exercices chapitre 5', $result[0]->title);
self::assertSame('2026-04-15', $result[0]->dueDate);
self::assertSame('Mathématiques', $result[0]->subjectName);
self::assertSame('#3b82f6', $result[0]->subjectColor);
self::assertSame('Jean Dupont', $result[0]->teacherName);
}
#[Test]
public function itSortsByDueDateAscending(): void
{
$this->givenHomework(title: 'Devoir lointain', dueDate: '2026-05-20');
$this->givenHomework(title: 'Devoir proche', dueDate: '2026-04-10');
$this->givenHomework(title: 'Devoir milieu', dueDate: '2026-04-25');
$handler = $this->createHandler();
$result = $handler(new GetStudentHomeworkQuery(
studentId: self::STUDENT_ID,
tenantId: self::TENANT_ID,
));
self::assertCount(3, $result);
self::assertSame('Devoir proche', $result[0]->title);
self::assertSame('Devoir milieu', $result[1]->title);
self::assertSame('Devoir lointain', $result[2]->title);
}
#[Test]
public function itFiltersDeletedHomework(): void
{
$homework = $this->givenHomework(title: 'Devoir supprimé', dueDate: '2026-04-15');
$homework->supprimer(new DateTimeImmutable('2026-03-20'));
$this->homeworkRepository->save($homework);
$this->givenHomework(title: 'Devoir actif', dueDate: '2026-04-20');
$handler = $this->createHandler();
$result = $handler(new GetStudentHomeworkQuery(
studentId: self::STUDENT_ID,
tenantId: self::TENANT_ID,
));
self::assertCount(1, $result);
self::assertSame('Devoir actif', $result[0]->title);
}
#[Test]
public function itFiltersBySubjectWhenProvided(): void
{
$this->givenHomework(title: 'Maths', dueDate: '2026-04-15', subjectId: self::SUBJECT_MATH_ID);
$this->givenHomework(title: 'Français', dueDate: '2026-04-16', subjectId: self::SUBJECT_FRENCH_ID);
$handler = $this->createHandler();
$result = $handler(new GetStudentHomeworkQuery(
studentId: self::STUDENT_ID,
tenantId: self::TENANT_ID,
subjectId: self::SUBJECT_MATH_ID,
));
self::assertCount(1, $result);
self::assertSame('Maths', $result[0]->title);
}
#[Test]
public function itExcludesHomeworkFromOtherClasses(): void
{
$this->givenHomework(title: 'Mon devoir', dueDate: '2026-04-15', classId: self::CLASS_ID);
$otherClassId = '550e8400-e29b-41d4-a716-446655440099';
$this->givenHomework(title: 'Autre classe', dueDate: '2026-04-16', classId: $otherClassId);
$handler = $this->createHandler();
$result = $handler(new GetStudentHomeworkQuery(
studentId: self::STUDENT_ID,
tenantId: self::TENANT_ID,
));
self::assertCount(1, $result);
self::assertSame('Mon devoir', $result[0]->title);
}
#[Test]
public function itIndicatesWhenHomeworkHasAttachments(): void
{
$homework = $this->givenHomework(title: 'Avec pièce jointe', dueDate: '2026-04-15');
$attachment = new HomeworkAttachment(
id: HomeworkAttachmentId::generate(),
filename: 'exercice.pdf',
filePath: '/uploads/exercice.pdf',
fileSize: 1024,
mimeType: 'application/pdf',
uploadedAt: new DateTimeImmutable('2026-03-12'),
);
$this->attachmentRepository->save($homework->id, $attachment);
$handler = $this->createHandler();
$result = $handler(new GetStudentHomeworkQuery(
studentId: self::STUDENT_ID,
tenantId: self::TENANT_ID,
));
self::assertCount(1, $result);
self::assertTrue($result[0]->hasAttachments);
}
#[Test]
public function itIndicatesWhenHomeworkHasNoAttachments(): void
{
$this->givenHomework(title: 'Sans pièce jointe', dueDate: '2026-04-15');
$handler = $this->createHandler();
$result = $handler(new GetStudentHomeworkQuery(
studentId: self::STUDENT_ID,
tenantId: self::TENANT_ID,
));
self::assertCount(1, $result);
self::assertFalse($result[0]->hasAttachments);
}
private function createHandler(?string $classId = self::CLASS_ID): GetStudentHomeworkHandler
{
$studentClassReader = new class($classId) implements StudentClassReader {
public function __construct(private readonly ?string $classId)
{
}
public function currentClassId(string $studentId, TenantId $tenantId): ?string
{
return $this->classId;
}
};
$displayReader = new class implements ScheduleDisplayReader {
public function subjectDisplay(string $tenantId, string ...$subjectIds): array
{
$map = [];
foreach ($subjectIds as $id) {
$map[$id] = ['name' => 'Mathématiques', 'color' => '#3b82f6'];
}
return $map;
}
public function teacherNames(string $tenantId, string ...$teacherIds): array
{
$map = [];
foreach ($teacherIds as $id) {
$map[$id] = 'Jean Dupont';
}
return $map;
}
};
return new GetStudentHomeworkHandler(
$studentClassReader,
$this->homeworkRepository,
$this->attachmentRepository,
$displayReader,
);
}
private function givenHomework(
string $title,
string $dueDate,
string $classId = self::CLASS_ID,
string $subjectId = self::SUBJECT_MATH_ID,
): Homework {
$homework = Homework::creer(
tenantId: TenantId::fromString(self::TENANT_ID),
classId: ClassId::fromString($classId),
subjectId: SubjectId::fromString($subjectId),
teacherId: UserId::fromString(self::TEACHER_ID),
title: $title,
description: null,
dueDate: new DateTimeImmutable($dueDate),
now: new DateTimeImmutable('2026-03-12 10:00:00'),
);
$this->homeworkRepository->save($homework);
return $homework;
}
}

View File

@@ -0,0 +1,570 @@
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-student-homework@example.com';
const STUDENT_PASSWORD = 'StudentHomework123';
const TEACHER_EMAIL = 'e2e-student-hw-teacher@example.com';
const TEACHER_PASSWORD = 'TeacherHomework123';
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}`;
}
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()
]);
}
test.describe('Student Homework Consultation (Story 5.7)', () => {
test.describe.configure({ mode: 'serial' });
const dueDate1 = getNextWeekday(5);
const dueDate2 = getNextWeekday(10);
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-StudentHW-6A', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
} catch {
// May already exist
}
// Ensure subjects exist
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-StudentHW-Maths', 'E2ESHWMAT', '#3b82f6', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
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-StudentHW-Français', 'E2ESHWFRA', '#ef4444', '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-StudentHW-6A' AND c.tenant_id = '${TENANT_ID}' ` +
`ON CONFLICT DO NOTHING`
);
// Clean up homework data
try {
runSql(
`DELETE FROM homework WHERE tenant_id = '${TENANT_ID}' AND class_id IN ` +
`(SELECT id FROM school_classes WHERE name = 'E2E-StudentHW-6A' AND tenant_id = '${TENANT_ID}')`
);
} catch {
// Table may not exist
}
// Seed homework for the student's class
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, 'Exercices chapitre 3', 'Faire les exercices 1 à 10 page 42', '${dueDate1}', 'published', NOW(), NOW() ` +
`FROM school_classes c, ` +
`(SELECT id FROM subjects WHERE code = 'E2ESHWMAT' 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-StudentHW-6A' AND c.tenant_id = '${TENANT_ID}'`
);
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, 'Rédaction sur les vacances', 'Écrire une rédaction de 200 mots', '${dueDate2}', 'published', NOW(), NOW() ` +
`FROM school_classes c, ` +
`(SELECT id FROM subjects WHERE code = 'E2ESHWFRA' 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-StudentHW-6A' AND c.tenant_id = '${TENANT_ID}'`
);
// Create a dummy attachment file in the container
execSync(
`docker compose -f "${composeFile}" exec -T php sh -c "mkdir -p /app/var/uploads && echo 'Test PDF content for E2E' > /app/var/uploads/e2e-exercice.pdf"`,
{ encoding: 'utf-8' }
);
// Seed attachment for "Exercices chapitre 3" homework
runSql(
`INSERT INTO homework_attachments (id, homework_id, filename, file_path, file_size, mime_type, uploaded_at) ` +
`SELECT gen_random_uuid(), h.id, 'exercice.pdf', '/app/var/uploads/e2e-exercice.pdf', 1024, 'application/pdf', NOW() ` +
`FROM homework h ` +
`WHERE h.title = 'Exercices chapitre 3' AND h.tenant_id = '${TENANT_ID}' ` +
`ON CONFLICT DO NOTHING`
);
clearCache();
});
// ======================================================================
// AC1: Liste devoirs
// ======================================================================
test.describe('AC1: Homework List', () => {
test('student can navigate to homework page', async ({ page }) => {
await loginAsStudent(page);
await page.goto(`${ALPHA_URL}/dashboard/homework`);
await expect(
page.getByRole('heading', { name: /mes devoirs/i })
).toBeVisible({ timeout: 15000 });
});
test('homework list shows pending items sorted by due date', async ({ page }) => {
await loginAsStudent(page);
await page.goto(`${ALPHA_URL}/dashboard/homework`);
await expect(
page.getByRole('heading', { name: /mes devoirs/i })
).toBeVisible({ timeout: 15000 });
// Wait for homework cards to appear
const cards = page.locator('.homework-card');
await expect(cards.first()).toBeVisible({ timeout: 10000 });
await expect(cards).toHaveCount(2);
// Verify sorted by due date (closest first)
const firstTitle = await cards.nth(0).locator('.card-title').textContent();
const secondTitle = await cards.nth(1).locator('.card-title').textContent();
expect(firstTitle).toBe('Exercices chapitre 3');
expect(secondTitle).toBe('Rédaction sur les vacances');
});
});
// ======================================================================
// AC2: Affichage devoir
// ======================================================================
test.describe('AC2: Homework Display', () => {
test('each homework card shows subject, title and due date', async ({ page }) => {
await loginAsStudent(page);
await page.goto(`${ALPHA_URL}/dashboard/homework`);
const card = page.locator('.homework-card').first();
await expect(card).toBeVisible({ timeout: 10000 });
// Subject name visible
await expect(card.locator('.subject-name')).toContainText(/maths/i);
// Title visible
await expect(card.locator('.card-title')).toContainText('Exercices chapitre 3');
// Due date visible
await expect(card.locator('.due-date')).toBeVisible();
// Status visible
await expect(card.locator('.status-badge')).toContainText(/à faire/i);
});
});
// ======================================================================
// AC3: Détail devoir
// ======================================================================
test.describe('AC3: Homework Detail', () => {
test('clicking a card shows the detail view', async ({ page }) => {
await loginAsStudent(page);
await page.goto(`${ALPHA_URL}/dashboard/homework`);
const card = page.locator('.homework-card').first();
await expect(card).toBeVisible({ timeout: 10000 });
await card.click();
// Detail view should appear
await expect(page.locator('.homework-detail')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.detail-title')).toContainText('Exercices chapitre 3');
await expect(page.locator('.detail-description')).toContainText('Faire les exercices 1 à 10 page 42');
// Teacher name visible
await expect(page.locator('.teacher-name')).toBeVisible();
});
test('back button returns to list', async ({ page }) => {
await loginAsStudent(page);
await page.goto(`${ALPHA_URL}/dashboard/homework`);
const card = page.locator('.homework-card').first();
await expect(card).toBeVisible({ timeout: 10000 });
await card.click();
await expect(page.locator('.homework-detail')).toBeVisible({ timeout: 10000 });
// Click back
await page.locator('.back-button').click();
await expect(page.locator('.homework-card').first()).toBeVisible({ timeout: 5000 });
});
});
// ======================================================================
// AC3 (extended): Fichiers joints dans le détail
// ======================================================================
test.describe('AC3: Homework Detail - Attachments', () => {
test('detail view shows attachment list when homework has attachments', async ({ page }) => {
await loginAsStudent(page);
await page.goto(`${ALPHA_URL}/dashboard/homework`);
// Click on "Exercices chapitre 3" which has an attachment
const card = page.locator('.homework-card').first();
await expect(card).toBeVisible({ timeout: 10000 });
await card.click();
await expect(page.locator('.homework-detail')).toBeVisible({ timeout: 10000 });
// Attachments section should be visible
await expect(page.locator('.detail-attachments')).toBeVisible();
await expect(page.locator('.attachment-item')).toBeVisible();
await expect(page.locator('.attachment-name')).toContainText('exercice.pdf');
});
});
// ======================================================================
// AC4: Téléchargement fichiers joints
// ======================================================================
test.describe('AC4: Attachment Download', () => {
test('clicking an attachment triggers file download', async ({ page }) => {
await loginAsStudent(page);
await page.goto(`${ALPHA_URL}/dashboard/homework`);
const card = page.locator('.homework-card').first();
await expect(card).toBeVisible({ timeout: 10000 });
await card.click();
await expect(page.locator('.homework-detail')).toBeVisible({ timeout: 10000 });
await expect(page.locator('.attachment-item')).toBeVisible();
// Intercept the attachment download request
const responsePromise = page.waitForResponse(
(resp) => resp.url().includes('/attachments/') && resp.status() === 200
);
await page.locator('.attachment-item').first().click();
const response = await responsePromise;
expect(response.status()).toBe(200);
});
});
// ======================================================================
// AC5: Filtrage par matière
// ======================================================================
test.describe('AC5: Subject Filter', () => {
test('filter buttons appear when multiple subjects exist', async ({ page }) => {
await loginAsStudent(page);
await page.goto(`${ALPHA_URL}/dashboard/homework`);
await expect(page.locator('.homework-card').first()).toBeVisible({ timeout: 10000 });
// Filter bar should be visible
const filterBar = page.locator('.filter-bar');
await expect(filterBar).toBeVisible();
// "Toutes" chip + 2 subject chips
const chips = filterBar.locator('.filter-chip');
await expect(chips).toHaveCount(3);
});
test('clicking a subject filter shows only that subject homework', async ({ page }) => {
await loginAsStudent(page);
await page.goto(`${ALPHA_URL}/dashboard/homework`);
await expect(page.locator('.homework-card').first()).toBeVisible({ timeout: 10000 });
// Click on Maths filter
await page.locator('.filter-chip', { hasText: /maths/i }).click();
const cards = page.locator('.homework-card');
await expect(cards).toHaveCount(1, { timeout: 5000 });
await expect(cards.first().locator('.card-title')).toContainText('Exercices chapitre 3');
});
test('clicking "Toutes" shows all homework again', async ({ page }) => {
await loginAsStudent(page);
await page.goto(`${ALPHA_URL}/dashboard/homework`);
await expect(page.locator('.homework-card').first()).toBeVisible({ timeout: 10000 });
// Filter then unfilter
await page.locator('.filter-chip', { hasText: /maths/i }).click();
await expect(page.locator('.homework-card')).toHaveCount(1, { timeout: 5000 });
await page.locator('.filter-chip', { hasText: /tous/i }).click();
await expect(page.locator('.homework-card')).toHaveCount(2, { timeout: 5000 });
});
});
// ======================================================================
// AC6: Marquage "Fait"
// ======================================================================
test.describe('AC6: Toggle Done', () => {
test('toggling done moves homework to "Terminés" section', async ({ page }) => {
await loginAsStudent(page);
await page.goto(`${ALPHA_URL}/dashboard/homework`);
const firstCard = page.locator('.homework-card').first();
await expect(firstCard).toBeVisible({ timeout: 10000 });
// Click toggle button on first card
const toggleBtn = firstCard.locator('.toggle-done');
await toggleBtn.click();
// A "Terminés" section should appear
await expect(page.getByText(/terminés/i)).toBeVisible({ timeout: 5000 });
// The card should now be in done state
const doneCard = page.locator('.homework-card.done');
await expect(doneCard).toBeVisible();
await expect(doneCard.locator('.status-badge')).toContainText(/fait/i);
});
test('done state persists after page reload', async ({ page }) => {
await loginAsStudent(page);
await page.goto(`${ALPHA_URL}/dashboard/homework`);
const firstCard = page.locator('.homework-card').first();
await expect(firstCard).toBeVisible({ timeout: 10000 });
// Mark as done
await firstCard.locator('.toggle-done').click();
await expect(page.locator('.homework-card.done')).toBeVisible({ timeout: 5000 });
// Reload the page
await page.reload();
await expect(page.locator('.homework-card').first()).toBeVisible({ timeout: 10000 });
// Done state should persist (localStorage)
await expect(page.locator('.homework-card.done')).toBeVisible({ timeout: 5000 });
});
test('toggling done again restores homework to pending section', async ({ page }) => {
await loginAsStudent(page);
await page.goto(`${ALPHA_URL}/dashboard/homework`);
const firstCard = page.locator('.homework-card').first();
await expect(firstCard).toBeVisible({ timeout: 10000 });
// Mark as done
await firstCard.locator('.toggle-done').click();
await expect(page.locator('.homework-card.done')).toBeVisible({ timeout: 5000 });
// Toggle back to undone
const doneCard = page.locator('.homework-card.done');
await doneCard.locator('.toggle-done').click();
// Card should no longer have done class
await expect(page.locator('.homework-card.done')).toHaveCount(0, { timeout: 5000 });
});
});
// ======================================================================
// AC7: Mode offline
// Skipped: Service Worker cannot cache cross-origin API requests in E2E
// (API runs on a different port). Works in production (same origin).
// ======================================================================
test.describe('AC7: Offline Mode', () => {
test.skip('cached homework is displayed when offline', async ({ page, context }) => {
await loginAsStudent(page);
await page.goto(`${ALPHA_URL}/dashboard/homework`);
// Wait for homework to load (populates Service Worker cache)
await expect(page.locator('.homework-card').first()).toBeVisible({ timeout: 10000 });
await expect(page.locator('.homework-card')).toHaveCount(2);
// Go offline
await context.setOffline(true);
// Reload the page — Service Worker should serve cached data
await page.reload();
// Offline banner should appear
await expect(page.locator('.offline-banner')).toBeVisible({ timeout: 10000 });
// Cached homework should still be visible
await expect(page.locator('.homework-card').first()).toBeVisible({ timeout: 10000 });
// Restore connectivity
await context.setOffline(false);
});
test.skip('marking homework as done works offline and persists', async ({ page, context }) => {
await loginAsStudent(page);
await page.goto(`${ALPHA_URL}/dashboard/homework`);
// Wait for homework to load
await expect(page.locator('.homework-card').first()).toBeVisible({ timeout: 10000 });
// Go offline
await context.setOffline(true);
await page.reload();
await expect(page.locator('.offline-banner')).toBeVisible({ timeout: 10000 });
// Mark homework as done while offline (should work via localStorage)
const firstCard = page.locator('.homework-card').first();
await expect(firstCard).toBeVisible({ timeout: 10000 });
await firstCard.locator('.toggle-done').click();
// Done state should be applied
await expect(page.locator('.homework-card.done')).toBeVisible({ timeout: 5000 });
// Come back online and reload
await context.setOffline(false);
await page.reload();
await expect(page.locator('.homework-card').first()).toBeVisible({ timeout: 10000 });
// Done state should persist (synced from localStorage)
await expect(page.locator('.homework-card.done')).toBeVisible({ timeout: 5000 });
});
});
// ======================================================================
// Dashboard integration
// ======================================================================
test.describe('Dashboard Widget', () => {
test('dashboard shows homework widget with real data', async ({ page }) => {
await loginAsStudent(page);
// Dashboard should load
await expect(
page.getByRole('heading', { name: /mon espace/i })
).toBeVisible({ timeout: 15000 });
// Homework section should show homework items
const homeworkSection = page.locator('.dashboard-grid').locator('section', { hasText: /mes devoirs/i });
await expect(homeworkSection).toBeVisible({ timeout: 10000 });
// Should have a "Voir tous les devoirs" link
await expect(page.getByText(/voir tous les devoirs/i)).toBeVisible({ timeout: 10000 });
});
test('dashboard "Voir tous les devoirs" link navigates to homework page', async ({ page }) => {
await loginAsStudent(page);
await expect(
page.getByRole('heading', { name: /mon espace/i })
).toBeVisible({ timeout: 15000 });
await page.getByText(/voir tous les devoirs/i).click();
await expect(
page.getByRole('heading', { name: /mes devoirs/i })
).toBeVisible({ timeout: 15000 });
});
test('clicking a homework item opens detail modal on dashboard', async ({ page }) => {
await loginAsStudent(page);
await expect(
page.getByRole('heading', { name: /mon espace/i })
).toBeVisible({ timeout: 15000 });
// Click on a homework item in the widget
const homeworkBtn = page.locator('button.homework-item').first();
await expect(homeworkBtn).toBeVisible({ timeout: 10000 });
await homeworkBtn.click();
// Modal with detail should appear
const modal = page.locator('[role="dialog"]');
await expect(modal).toBeVisible({ timeout: 10000 });
await expect(modal.locator('.detail-title')).toBeVisible();
await expect(modal.locator('.teacher-name')).toBeVisible();
});
test('homework detail modal closes with X button', async ({ page }) => {
await loginAsStudent(page);
await expect(
page.getByRole('heading', { name: /mon espace/i })
).toBeVisible({ timeout: 15000 });
const homeworkBtn = page.locator('button.homework-item').first();
await expect(homeworkBtn).toBeVisible({ timeout: 10000 });
await homeworkBtn.click();
const modal = page.locator('[role="dialog"]');
await expect(modal).toBeVisible({ timeout: 10000 });
// Close modal
await page.locator('.homework-modal-close').click();
await expect(modal).not.toBeVisible({ timeout: 5000 });
});
});
});

View File

@@ -1,8 +1,12 @@
<script lang="ts"> <script lang="ts">
import type { DemoData } from '$types'; import type { DemoData } from '$types';
import type { ScheduleSlot } from '$lib/features/schedule/api/schedule'; import type { ScheduleSlot } from '$lib/features/schedule/api/schedule';
import type { StudentHomework, StudentHomeworkDetail } from '$lib/features/homework/api/studentHomework';
import { fetchDaySchedule, fetchNextClass } from '$lib/features/schedule/api/schedule'; import { fetchDaySchedule, fetchNextClass } from '$lib/features/schedule/api/schedule';
import { fetchStudentHomework, fetchHomeworkDetail } from '$lib/features/homework/api/studentHomework';
import HomeworkDetail from '$lib/components/organisms/StudentHomework/HomeworkDetail.svelte';
import { recordSync } from '$lib/features/schedule/stores/scheduleCache.svelte'; import { recordSync } from '$lib/features/schedule/stores/scheduleCache.svelte';
import { getHomeworkStatuses } from '$lib/features/homework/stores/homeworkStatus.svelte';
import DashboardSection from '$lib/components/molecules/DashboardSection.svelte'; import DashboardSection from '$lib/components/molecules/DashboardSection.svelte';
import SkeletonList from '$lib/components/atoms/Skeleton/SkeletonList.svelte'; import SkeletonList from '$lib/components/atoms/Skeleton/SkeletonList.svelte';
import ScheduleWidget from '$lib/components/organisms/StudentSchedule/ScheduleWidget.svelte'; import ScheduleWidget from '$lib/components/organisms/StudentSchedule/ScheduleWidget.svelte';
@@ -28,6 +32,16 @@
let scheduleLoading = $state(false); let scheduleLoading = $state(false);
let scheduleError = $state<string | null>(null); let scheduleError = $state<string | null>(null);
// Homework widget state
let studentHomeworks = $state<StudentHomework[]>([]);
let homeworkLoading = $state(false);
let hwStatuses = $derived(getHomeworkStatuses());
let pendingHomeworks = $derived(
studentHomeworks.filter(hw => !hwStatuses[hw.id]?.done).slice(0, 5)
);
function formatLocalDate(d: Date): string { function formatLocalDate(d: Date): string {
const y = d.getFullYear(); const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0'); const m = String(d.getMonth() + 1).padStart(2, '0');
@@ -35,6 +49,11 @@
return `${y}-${m}-${day}`; return `${y}-${m}-${day}`;
} }
function formatShortDate(dateStr: string): string {
const date = new Date(dateStr + 'T00:00:00');
return date.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' });
}
async function loadTodaySchedule() { async function loadTodaySchedule() {
scheduleLoading = true; scheduleLoading = true;
scheduleError = null; scheduleError = null;
@@ -57,9 +76,47 @@
} }
} }
if (isEleve) { async function loadHomeworks() {
loadTodaySchedule(); homeworkLoading = true;
try {
studentHomeworks = await fetchStudentHomework();
} catch {
// Silently fail on dashboard widget
} finally {
homeworkLoading = false;
}
} }
// Homework detail modal
let selectedHomeworkDetail = $state<StudentHomeworkDetail | null>(null);
async function openHomeworkDetail(homeworkId: string) {
try {
selectedHomeworkDetail = await fetchHomeworkDetail(homeworkId);
} catch {
// Fallback: navigate to full page
window.location.href = '/dashboard/homework';
}
}
function closeHomeworkDetail() {
selectedHomeworkDetail = null;
}
function handleOverlayClick(e: MouseEvent) {
if (e.target === e.currentTarget) closeHomeworkDetail();
}
function handleModalKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') closeHomeworkDetail();
}
$effect(() => {
if (!isEleve) return;
void loadTodaySchedule();
void loadHomeworks();
});
</script> </script>
<div class="dashboard-student"> <div class="dashboard-student">
@@ -148,11 +205,34 @@
<!-- Devoirs Section --> <!-- Devoirs Section -->
<DashboardSection <DashboardSection
title="Mes devoirs" title="Mes devoirs"
subtitle={hasRealData ? "À faire" : undefined} subtitle={isEleve ? "À faire" : (hasRealData ? "À faire" : undefined)}
isPlaceholder={!hasRealData} isPlaceholder={!isEleve && !hasRealData}
placeholderMessage={isMinor ? "Tes devoirs s'afficheront ici" : "Vos devoirs s'afficheront ici"} placeholderMessage={isMinor ? "Tes devoirs s'afficheront ici" : "Vos devoirs s'afficheront ici"}
> >
{#if hasRealData} {#if isEleve}
{#if homeworkLoading}
<SkeletonList items={3} message="Chargement des devoirs..." />
{:else if pendingHomeworks.length === 0}
<p class="empty-homework">Aucun devoir à faire</p>
{:else}
<ul class="homework-list">
{#each pendingHomeworks as homework}
<li>
<button class="homework-item" style:border-left-color={homework.subjectColor ?? '#3b82f6'} onclick={() => openHomeworkDetail(homework.id)}>
<div class="homework-header">
<span class="homework-subject" style:color={homework.subjectColor ?? '#3b82f6'}>{homework.subjectName}</span>
</div>
<span class="homework-title">{homework.title}</span>
<span class="homework-due">Pour le {formatShortDate(homework.dueDate)}</span>
</button>
</li>
{/each}
</ul>
<a href="/dashboard/homework" class="view-all-link">
Voir tous les devoirs →
</a>
{/if}
{:else if hasRealData}
{#if isLoading} {#if isLoading}
<SkeletonList items={3} message="Chargement des devoirs..." /> <SkeletonList items={3} message="Chargement des devoirs..." />
{:else} {:else}
@@ -178,6 +258,16 @@
</div> </div>
</div> </div>
{#if selectedHomeworkDetail}
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div class="homework-modal-overlay" onclick={handleOverlayClick} onkeydown={handleModalKeydown} role="presentation">
<div class="homework-modal" role="dialog" aria-modal="true" aria-label="Détail du devoir">
<button class="homework-modal-close" onclick={closeHomeworkDetail} aria-label="Fermer">&times;</button>
<HomeworkDetail detail={selectedHomeworkDetail} onBack={closeHomeworkDetail} />
</div>
</div>
{/if}
<style> <style>
.dashboard-student { .dashboard-student {
display: flex; display: flex;
@@ -327,10 +417,21 @@
} }
.homework-item { .homework-item {
display: block;
width: 100%;
padding: 0.75rem; padding: 0.75rem;
background: #f9fafb; background: #f9fafb;
border: none;
border-radius: 0.5rem; border-radius: 0.5rem;
border-left: 3px solid #3b82f6; border-left: 3px solid #3b82f6;
text-align: left;
cursor: pointer;
transition: background 0.15s;
font: inherit;
}
button.homework-item:hover {
background: #f3f4f6;
} }
.homework-item.done { .homework-item.done {
@@ -380,4 +481,67 @@
font-size: 0.875rem; font-size: 0.875rem;
color: #6b7280; color: #6b7280;
} }
.empty-homework {
margin: 0;
text-align: center;
padding: 1rem;
color: #6b7280;
font-size: 0.875rem;
}
.view-all-link {
display: block;
text-align: center;
margin-top: 0.75rem;
color: #3b82f6;
font-size: 0.875rem;
font-weight: 500;
text-decoration: none;
}
.view-all-link:hover {
color: #2563eb;
}
/* Homework detail modal */
.homework-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
padding: 1rem;
}
.homework-modal {
position: relative;
background: white;
border-radius: 0.75rem;
padding: 1.5rem;
max-width: 40rem;
width: 100%;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
.homework-modal-close {
position: absolute;
top: 0.75rem;
right: 0.75rem;
background: none;
border: none;
font-size: 1.5rem;
color: #6b7280;
cursor: pointer;
line-height: 1;
padding: 0.25rem;
}
.homework-modal-close:hover {
color: #1f2937;
}
</style> </style>

View File

@@ -0,0 +1,181 @@
<script lang="ts">
import type { StudentHomework } from '$lib/features/homework/api/studentHomework';
let {
homework,
isDone = false,
onToggleDone,
onclick
}: {
homework: StudentHomework;
isDone?: boolean;
onToggleDone?: (id: string) => void;
onclick?: (id: string) => void;
} = $props();
function formatDueDate(dateStr: string): string {
const date = new Date(dateStr + 'T00:00:00');
return date.toLocaleDateString('fr-FR', {
weekday: 'short',
day: 'numeric',
month: 'long'
});
}
function handleToggle(e: Event) {
e.stopPropagation();
onToggleDone?.(homework.id);
}
function handleClick() {
onclick?.(homework.id);
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
handleClick();
}
}
</script>
<div
class="homework-card"
class:done={isDone}
style:border-left-color={homework.subjectColor ?? '#3b82f6'}
role="button"
tabindex="0"
onclick={handleClick}
onkeydown={handleKeydown}
>
<div class="card-header">
<span class="subject-name" style:color={homework.subjectColor ?? '#3b82f6'}>
{homework.subjectName}
</span>
<button
class="toggle-done"
class:checked={isDone}
onclick={handleToggle}
aria-label={isDone ? `Marquer "${homework.title}" comme à faire` : `Marquer "${homework.title}" comme fait`}
>
{#if isDone}
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
<rect x="1" y="1" width="16" height="16" rx="3" fill="#22c55e" stroke="#22c55e" stroke-width="2"/>
<path d="M5 9l3 3 5-6" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
{:else}
<svg width="18" height="18" viewBox="0 0 18 18" fill="none">
<rect x="1" y="1" width="16" height="16" rx="3" stroke="#d1d5db" stroke-width="2"/>
</svg>
{/if}
</button>
</div>
<h3 class="card-title">{homework.title}</h3>
<div class="card-footer">
<span class="due-date">Pour le {formatDueDate(homework.dueDate)}</span>
<span class="status-badge" class:done={isDone}>
{isDone ? 'Fait' : 'À faire'}
</span>
{#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">
<path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48"/>
</svg>
</span>
{/if}
</div>
</div>
<style>
.homework-card {
padding: 0.75rem;
background: #f9fafb;
border-radius: 0.5rem;
border-left: 3px solid #3b82f6;
cursor: pointer;
transition: background 0.15s, box-shadow 0.15s;
}
.homework-card:hover {
background: #f3f4f6;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.homework-card:focus-visible {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
.homework-card.done {
opacity: 0.6;
border-left-color: #22c55e !important;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.25rem;
}
.subject-name {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.toggle-done {
background: none;
border: none;
padding: 0.125rem;
cursor: pointer;
display: flex;
align-items: center;
}
.card-title {
margin: 0 0 0.25rem;
font-size: 0.9375rem;
font-weight: 500;
color: #1f2937;
}
.homework-card.done .card-title {
text-decoration: line-through;
color: #6b7280;
}
.card-footer {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.due-date {
font-size: 0.8125rem;
color: #6b7280;
}
.status-badge {
font-size: 0.75rem;
font-weight: 600;
color: #92400e;
background: #fef3c7;
border-radius: 999px;
padding: 0.125rem 0.5rem;
}
.status-badge.done {
color: #166534;
background: #dcfce7;
}
.attachment-indicator {
color: #6b7280;
display: flex;
align-items: center;
}
</style>

View File

@@ -0,0 +1,238 @@
<script lang="ts">
import type { StudentHomeworkDetail } from '$lib/features/homework/api/studentHomework';
import { getAttachmentUrl } from '$lib/features/homework/api/studentHomework';
import { authenticatedFetch } from '$lib/auth';
let {
detail,
onBack
}: {
detail: StudentHomeworkDetail;
onBack: () => void;
} = $props();
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'
});
}
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 shouldOpenInline(mimeType: string): boolean {
return mimeType === 'application/pdf' || mimeType.startsWith('image/') || mimeType.startsWith('text/');
}
function triggerBlobNavigation(blobUrl: string, filename: string, inline: boolean): void {
const link = document.createElement('a');
link.href = blobUrl;
if (inline) {
link.target = '_blank';
link.rel = 'noopener noreferrer';
} else {
link.download = filename;
}
link.click();
setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000);
}
let downloadError = $state<string | null>(null);
async function downloadAttachment(attachmentId: string, filename: string, mimeType: string) {
downloadError = null;
const url = getAttachmentUrl(detail.id, 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);
triggerBlobNavigation(blobUrl, filename, shouldOpenInline(mimeType));
} catch {
downloadError = `Impossible de télécharger "${filename}".`;
}
}
</script>
<div class="homework-detail">
<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
</button>
<header class="detail-header" style:border-left-color={detail.subjectColor ?? '#3b82f6'}>
<span class="subject-name" style:color={detail.subjectColor ?? '#3b82f6'}>
{detail.subjectName}
</span>
<h2 class="detail-title">{detail.title}</h2>
<div class="detail-meta">
<span class="due-date">Pour le {formatDueDate(detail.dueDate)}</span>
<span class="teacher-name">Par {detail.teacherName}</span>
</div>
</header>
{#if detail.description}
<section class="detail-description">
<h3>Description</h3>
<p>{detail.description}</p>
</section>
{/if}
{#if detail.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 detail.attachments as attachment}
<li>
<button
class="attachment-item"
onclick={() =>
downloadAttachment(attachment.id, attachment.filename, attachment.mimeType)}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48"/>
</svg>
<span class="attachment-name">{attachment.filename}</span>
<span class="attachment-size">{formatFileSize(attachment.fileSize)}</span>
</button>
</li>
{/each}
</ul>
</section>
{/if}
</div>
<style>
.homework-detail {
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;
}
.detail-header {
border-left: 4px solid #3b82f6;
padding-left: 1rem;
}
.subject-name {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.detail-title {
margin: 0.25rem 0 0.5rem;
font-size: 1.25rem;
font-weight: 600;
color: #1f2937;
}
.detail-meta {
display: flex;
flex-direction: column;
gap: 0.125rem;
font-size: 0.875rem;
color: #6b7280;
}
.detail-description h3,
.detail-attachments h3 {
margin: 0 0 0.5rem;
font-size: 0.9375rem;
font-weight: 600;
color: #374151;
}
.detail-description p {
margin: 0;
color: #4b5563;
line-height: 1.6;
white-space: pre-wrap;
}
.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>

View File

@@ -0,0 +1,277 @@
<script lang="ts">
import type { StudentHomework, StudentHomeworkDetail as HomeworkDetailType } from '$lib/features/homework/api/studentHomework';
import { fetchStudentHomework, fetchHomeworkDetail } from '$lib/features/homework/api/studentHomework';
import { toggleHomeworkDone, getHomeworkStatuses } from '$lib/features/homework/stores/homeworkStatus.svelte';
import { isOffline } from '$lib/features/schedule/stores/scheduleCache.svelte';
import HomeworkCard from './HomeworkCard.svelte';
import HomeworkDetail from './HomeworkDetail.svelte';
import SkeletonList from '$lib/components/atoms/Skeleton/SkeletonList.svelte';
let homeworks = $state<StudentHomework[]>([]);
let allSubjects = $state<{ id: string; name: string; color: string | null }[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
let selectedSubjectId = $state<string | null>(null);
let selectedDetail = $state<HomeworkDetailType | null>(null);
let detailLoading = $state(false);
let statuses = $derived(getHomeworkStatuses());
let pendingHomeworks = $derived(
homeworks.filter(hw => !statuses[hw.id]?.done)
);
let doneHomeworks = $derived(
homeworks.filter(hw => statuses[hw.id]?.done)
);
function extractSubjects(hws: StudentHomework[]): { id: string; name: string; color: string | null }[] {
const map = new Map<string, { id: string; name: string; color: string | null }>();
for (const hw of hws) {
if (!map.has(hw.subjectId)) {
map.set(hw.subjectId, { id: hw.subjectId, name: hw.subjectName, color: hw.subjectColor });
}
}
return Array.from(map.values());
}
async function loadHomeworks() {
loading = true;
error = null;
try {
homeworks = await fetchStudentHomework(selectedSubjectId ?? undefined);
if (selectedSubjectId === null) {
allSubjects = extractSubjects(homeworks);
}
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur de chargement';
} finally {
loading = false;
}
}
function handleFilterChange(subjectId: string | null) {
selectedSubjectId = subjectId;
}
async function handleCardClick(homeworkId: string) {
detailLoading = true;
try {
selectedDetail = await fetchHomeworkDetail(homeworkId);
} catch {
// Stay on list if detail fails
} finally {
detailLoading = false;
}
}
function handleBack() {
selectedDetail = null;
}
function handleToggleDone(homeworkId: string) {
toggleHomeworkDone(homeworkId);
}
$effect(() => {
void selectedSubjectId;
void loadHomeworks();
});
</script>
{#if selectedDetail}
<HomeworkDetail detail={selectedDetail} onBack={handleBack} />
{:else}
<div class="student-homework">
{#if isOffline()}
<div class="offline-banner" role="status">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="1" y1="1" x2="23" y2="23"/>
<path d="M16.72 11.06A10.94 10.94 0 0 1 19 12.55"/>
<path d="M5 12.55a10.94 10.94 0 0 1 5.17-2.39"/>
<path d="M10.71 5.05A16 16 0 0 1 22.56 9"/>
<path d="M1.42 9a15.91 15.91 0 0 1 4.7-2.88"/>
<path d="M8.53 16.11a6 6 0 0 1 6.95 0"/>
<line x1="12" y1="20" x2="12.01" y2="20"/>
</svg>
Mode hors ligne
</div>
{/if}
{#if allSubjects.length > 1}
<div class="filter-bar" role="toolbar" aria-label="Filtrer par matière">
<button
class="filter-chip"
class:active={selectedSubjectId === null}
onclick={() => handleFilterChange(null)}
>
Tous
</button>
{#each allSubjects as subject}
<button
class="filter-chip"
class:active={selectedSubjectId === subject.id}
style:--chip-color={subject.color ?? '#3b82f6'}
onclick={() => handleFilterChange(subject.id)}
>
{subject.name}
</button>
{/each}
</div>
{/if}
{#if loading}
<SkeletonList items={4} message="Chargement des devoirs..." />
{:else if error}
<div class="error-message" role="alert">
<p>{error}</p>
<button onclick={() => void loadHomeworks()}>Réessayer</button>
</div>
{:else if homeworks.length === 0}
<div class="empty-state">
<p>Aucun devoir pour le moment</p>
</div>
{:else}
{#if pendingHomeworks.length > 0}
<section>
<h3 class="section-title">À faire ({pendingHomeworks.length})</h3>
<ul class="homework-list" role="list">
{#each pendingHomeworks as hw (hw.id)}
<li>
<HomeworkCard
homework={hw}
isDone={false}
onToggleDone={handleToggleDone}
onclick={handleCardClick}
/>
</li>
{/each}
</ul>
</section>
{/if}
{#if doneHomeworks.length > 0}
<section>
<h3 class="section-title">Terminés ({doneHomeworks.length})</h3>
<ul class="homework-list" role="list">
{#each doneHomeworks as hw (hw.id)}
<li>
<HomeworkCard
homework={hw}
isDone={true}
onToggleDone={handleToggleDone}
onclick={handleCardClick}
/>
</li>
{/each}
</ul>
</section>
{/if}
{/if}
{#if detailLoading}
<div class="detail-loading-overlay" role="status">
<p>Chargement...</p>
</div>
{/if}
</div>
{/if}
<style>
.student-homework {
display: flex;
flex-direction: column;
gap: 1rem;
}
.offline-banner {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: #fef3c7;
border: 1px solid #f59e0b;
border-radius: 0.5rem;
color: #92400e;
font-size: 0.8125rem;
font-weight: 500;
}
.filter-bar {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.filter-chip {
padding: 0.375rem 0.75rem;
border: 1px solid #e5e7eb;
border-radius: 1rem;
background: white;
font-size: 0.8125rem;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.filter-chip:hover {
background: #f3f4f6;
}
.filter-chip.active {
background: var(--chip-color, #3b82f6);
color: white;
border-color: var(--chip-color, #3b82f6);
}
.section-title {
margin: 0 0 0.5rem;
font-size: 0.875rem;
font-weight: 600;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.025em;
}
.homework-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.empty-state {
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;
}
.detail-loading-overlay {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.8);
z-index: 50;
}
</style>

View File

@@ -0,0 +1,76 @@
import { getApiBaseUrl } from '$lib/api';
import { authenticatedFetch } from '$lib/auth';
export interface StudentHomework {
id: string;
subjectId: string;
subjectName: string;
subjectColor: string | null;
teacherId: string;
teacherName: string;
title: string;
description: string | null;
dueDate: string;
createdAt: string;
hasAttachments: boolean;
}
export interface HomeworkAttachment {
id: string;
filename: string;
fileSize: number;
mimeType: string;
}
export interface StudentHomeworkDetail {
id: string;
subjectId: string;
subjectName: string;
subjectColor: string | null;
teacherId: string;
teacherName: string;
title: string;
description: string | null;
dueDate: string;
createdAt: string;
attachments: HomeworkAttachment[];
}
/**
* Récupère la liste des devoirs pour l'élève connecté.
*/
export async function fetchStudentHomework(subjectId?: string): Promise<StudentHomework[]> {
const apiUrl = getApiBaseUrl();
const params = subjectId ? `?subjectId=${encodeURIComponent(subjectId)}` : '';
const response = await authenticatedFetch(`${apiUrl}/me/homework${params}`);
if (!response.ok) {
throw new Error(`Erreur lors du chargement des devoirs (${response.status})`);
}
const json = await response.json();
return json.data ?? [];
}
/**
* Récupère le détail d'un devoir.
*/
export async function fetchHomeworkDetail(homeworkId: string): Promise<StudentHomeworkDetail> {
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(`${apiUrl}/me/homework/${homeworkId}`);
if (!response.ok) {
throw new Error(`Erreur lors du chargement du devoir (${response.status})`);
}
const json = await response.json();
return json.data;
}
/**
* Retourne l'URL de téléchargement d'une pièce jointe.
*/
export function getAttachmentUrl(homeworkId: string, attachmentId: string): string {
const apiUrl = getApiBaseUrl();
return `${apiUrl}/me/homework/${homeworkId}/attachments/${attachmentId}`;
}

View File

@@ -0,0 +1,60 @@
import { browser } from '$app/environment';
const STORAGE_KEY = 'classeo:homework:status';
interface HomeworkStatusEntry {
done: boolean;
doneAt: number | null;
}
type StatusMap = Record<string, HomeworkStatusEntry>;
function loadStatuses(): StatusMap {
if (!browser) return {};
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? (JSON.parse(raw) as StatusMap) : {};
} catch {
return {};
}
}
function saveStatuses(statuses: StatusMap): void {
if (!browser) return;
localStorage.setItem(STORAGE_KEY, JSON.stringify(statuses));
}
let statuses = $state<StatusMap>(loadStatuses());
/**
* Marque un devoir comme "fait" ou "à faire".
*/
export function toggleHomeworkDone(homeworkId: string): void {
const current = statuses[homeworkId];
const isDone = current?.done ?? false;
statuses = {
...statuses,
[homeworkId]: {
done: !isDone,
doneAt: !isDone ? Date.now() : null,
},
};
saveStatuses(statuses);
}
/**
* Vérifie si un devoir est marqué comme "fait".
*/
export function isHomeworkDone(homeworkId: string): boolean {
return statuses[homeworkId]?.done ?? false;
}
/**
* Retourne le map complet des statuts (réactif via $state).
*/
export function getHomeworkStatuses(): StatusMap {
return statuses;
}

View File

@@ -0,0 +1,34 @@
<script lang="ts">
import StudentHomeworkList from '$lib/components/organisms/StudentHomework/StudentHomeworkList.svelte';
</script>
<svelte:head>
<title>Mes devoirs - Classeo</title>
</svelte:head>
<div class="page-container">
<header class="page-header">
<h1>Mes devoirs</h1>
</header>
<StudentHomeworkList />
</div>
<style>
.page-container {
max-width: 48rem;
margin: 0 auto;
padding: 1rem;
}
.page-header {
margin-bottom: 1.5rem;
}
.page-header h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
color: #1f2937;
}
</style>

View File

@@ -47,13 +47,43 @@ export default defineConfig({
} }
] ]
}, },
workbox: { workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,webp,woff,woff2}'], globPatterns: ['**/*.{js,css,html,ico,png,svg,webp,woff,woff2}'],
runtimeCaching: [ runtimeCaching: [
{ {
urlPattern: /\/api\/me\/schedule\//, urlPattern: /\/api\/me\/homework(?:\?.*)?$/,
handler: 'NetworkFirst', handler: 'NetworkFirst',
options: { options: {
cacheName: 'student-homework-list-v1',
expiration: {
maxEntries: 31,
maxAgeSeconds: 30 * 24 * 60 * 60
},
networkTimeoutSeconds: 5,
cacheableResponse: {
statuses: [0, 200]
}
}
},
{
urlPattern: /\/api\/me\/homework\/[^/]+(?:\/attachments\/[^/]+)?$/,
handler: 'NetworkFirst',
options: {
cacheName: 'student-homework-detail-v1',
expiration: {
maxEntries: 120,
maxAgeSeconds: 30 * 24 * 60 * 60
},
networkTimeoutSeconds: 5,
cacheableResponse: {
statuses: [0, 200]
}
}
},
{
urlPattern: /\/api\/me\/schedule\//,
handler: 'NetworkFirst',
options: {
cacheName: 'schedule-v1', cacheName: 'schedule-v1',
expiration: { expiration: {
maxEntries: 90, maxEntries: 90,