Compare commits

...

2 Commits

Author SHA1 Message Date
1a990951a7 feat: Permettre au parent de consulter les devoirs de ses enfants
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
Les parents avaient accès à l'emploi du temps de leurs enfants mais
pas à leurs devoirs. Sans cette visibilité, ils ne pouvaient pas
accompagner efficacement le travail scolaire à la maison, notamment
identifier les devoirs urgents ou contacter l'enseignant en cas
de besoin.

Le parent dispose désormais d'une vue consolidée multi-enfants avec
filtrage par enfant et par matière, badges d'urgence différenciés
(en retard / aujourd'hui / pour demain), lien de contact enseignant
pré-rempli, et cache offline scopé par utilisateur.
2026-03-23 00:34:55 +01:00
2e2328c6ca 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).
2026-03-22 17:01:32 +01:00
36 changed files with 4484 additions and 23 deletions

View File

@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Query\GetChildrenHomework;
use App\Scolarite\Application\Query\GetStudentHomework\StudentHomeworkDto;
final readonly class ChildHomeworkDto
{
/**
* @param array<StudentHomeworkDto> $homework
*/
public function __construct(
public string $childId,
public string $firstName,
public string $lastName,
public array $homework,
) {
}
}

View File

@@ -0,0 +1,69 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Query\GetChildrenHomework;
use App\Scolarite\Application\Port\ParentChildrenReader;
use App\Scolarite\Application\Port\ScheduleDisplayReader;
use App\Scolarite\Application\Port\StudentClassReader;
use App\Scolarite\Application\Query\GetStudentHomework\StudentHomeworkDetailDto;
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;
final readonly class GetChildrenHomeworkDetailHandler
{
public function __construct(
private ParentChildrenReader $parentChildrenReader,
private StudentClassReader $studentClassReader,
private HomeworkRepository $homeworkRepository,
private HomeworkAttachmentRepository $attachmentRepository,
private ScheduleDisplayReader $displayReader,
) {
}
public function __invoke(string $parentId, string $tenantId, string $homeworkId): ?StudentHomeworkDetailDto
{
$tid = TenantId::fromString($tenantId);
$homework = $this->homeworkRepository->findById(HomeworkId::fromString($homeworkId), $tid);
if ($homework === null) {
return null;
}
if (!$this->parentHasChildInClass($parentId, $tid, (string) $homework->classId)) {
return null;
}
$attachments = $this->attachmentRepository->findByHomeworkId($homework->id);
$subjects = $this->displayReader->subjectDisplay($tenantId, (string) $homework->subjectId);
$teacherNames = $this->displayReader->teacherNames($tenantId, (string) $homework->teacherId);
return StudentHomeworkDetailDto::fromDomain(
$homework,
$subjects[(string) $homework->subjectId]['name'] ?? '',
$subjects[(string) $homework->subjectId]['color'] ?? null,
$teacherNames[(string) $homework->teacherId] ?? '',
$attachments,
);
}
private function parentHasChildInClass(string $parentId, TenantId $tenantId, string $homeworkClassId): bool
{
$children = $this->parentChildrenReader->childrenOf($parentId, $tenantId);
foreach ($children as $child) {
$classId = $this->studentClassReader->currentClassId($child['studentId'], $tenantId);
if ($classId === $homeworkClassId) {
return true;
}
}
return false;
}
}

View File

@@ -0,0 +1,140 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Query\GetChildrenHomework;
use App\Administration\Domain\Model\SchoolClass\ClassId;
use App\Scolarite\Application\Port\ParentChildrenReader;
use App\Scolarite\Application\Port\ScheduleDisplayReader;
use App\Scolarite\Application\Port\StudentClassReader;
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\Shared\Domain\Tenant\TenantId;
use function array_filter;
use function array_map;
use function array_unique;
use function array_values;
use DateTimeImmutable;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use function usort;
#[AsMessageHandler(bus: 'query.bus')]
final readonly class GetChildrenHomeworkHandler
{
public function __construct(
private ParentChildrenReader $parentChildrenReader,
private StudentClassReader $studentClassReader,
private HomeworkRepository $homeworkRepository,
private HomeworkAttachmentRepository $attachmentRepository,
private ScheduleDisplayReader $displayReader,
) {
}
/** @return array<ChildHomeworkDto> */
public function __invoke(GetChildrenHomeworkQuery $query): array
{
$tenantId = TenantId::fromString($query->tenantId);
$allChildren = $this->parentChildrenReader->childrenOf($query->parentId, $tenantId);
if ($allChildren === []) {
return [];
}
$children = $query->childId !== null
? array_values(array_filter($allChildren, static fn (array $c): bool => $c['studentId'] === $query->childId))
: $allChildren;
if ($children === []) {
return [];
}
$result = [];
foreach ($children as $child) {
$classId = $this->studentClassReader->currentClassId($child['studentId'], $tenantId);
if ($classId === null) {
$result[] = new ChildHomeworkDto(
childId: $child['studentId'],
firstName: $child['firstName'],
lastName: $child['lastName'],
homework: [],
);
continue;
}
$homeworks = $this->homeworkRepository->findByClass(ClassId::fromString($classId), $tenantId);
$today = new DateTimeImmutable('today');
$homeworks = array_values(array_filter(
$homeworks,
static fn (Homework $h): bool => $h->dueDate >= $today,
));
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);
$enriched = $this->enrichHomeworks($homeworks, $query->tenantId);
$result[] = new ChildHomeworkDto(
childId: $child['studentId'],
firstName: $child['firstName'],
lastName: $child['lastName'],
homework: $enriched,
);
}
return $result;
}
/**
* @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,16 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Application\Query\GetChildrenHomework;
final readonly class GetChildrenHomeworkQuery
{
public function __construct(
public string $parentId,
public string $tenantId,
public ?string $childId = null,
public ?string $subjectId = null,
) {
}
}

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> */
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;
}

View File

@@ -0,0 +1,232 @@
<?php
declare(strict_types=1);
namespace App\Scolarite\Infrastructure\Api\Controller;
use App\Administration\Infrastructure\Security\SecurityUser;
use App\Scolarite\Application\Query\GetChildrenHomework\ChildHomeworkDto;
use App\Scolarite\Application\Query\GetChildrenHomework\GetChildrenHomeworkDetailHandler;
use App\Scolarite\Application\Query\GetChildrenHomework\GetChildrenHomeworkHandler;
use App\Scolarite\Application\Query\GetChildrenHomework\GetChildrenHomeworkQuery;
use App\Scolarite\Application\Query\GetStudentHomework\StudentHomeworkDetailDto;
use App\Scolarite\Application\Query\GetStudentHomework\StudentHomeworkDto;
use App\Scolarite\Domain\Model\Homework\HomeworkId;
use App\Scolarite\Domain\Repository\HomeworkAttachmentRepository;
use App\Scolarite\Domain\Repository\HomeworkRepository;
use App\Scolarite\Infrastructure\Security\HomeworkParentVoter;
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;
/**
* Endpoints de consultation des devoirs des enfants pour le parent connecté.
*/
#[IsGranted(HomeworkParentVoter::VIEW)]
final readonly class ParentHomeworkController
{
public function __construct(
private Security $security,
private GetChildrenHomeworkHandler $handler,
private GetChildrenHomeworkDetailHandler $detailHandler,
private HomeworkRepository $homeworkRepository,
private HomeworkAttachmentRepository $attachmentRepository,
#[Autowire('%kernel.project_dir%/var/uploads')]
private string $uploadsDir,
) {
}
/**
* Devoirs d'un enfant spécifique.
*/
#[Route('/api/me/children/{childId}/homework', name: 'api_parent_child_homework', methods: ['GET'])]
public function childHomework(string $childId, Request $request): JsonResponse
{
$user = $this->getSecurityUser();
$subjectId = $request->query->get('subjectId');
$children = ($this->handler)(new GetChildrenHomeworkQuery(
parentId: $user->userId(),
tenantId: $user->tenantId(),
childId: $childId,
subjectId: is_string($subjectId) && $subjectId !== '' ? $subjectId : null,
));
if ($children === []) {
throw new NotFoundHttpException('Enfant non trouvé ou non lié à ce parent.');
}
return new JsonResponse([
'data' => $this->serializeChild($children[0]),
]);
}
/**
* Vue consolidée des devoirs de tous les enfants.
*/
#[Route('/api/me/children/homework', name: 'api_parent_children_homework', methods: ['GET'])]
public function allChildrenHomework(Request $request): JsonResponse
{
$user = $this->getSecurityUser();
$subjectId = $request->query->get('subjectId');
$children = ($this->handler)(new GetChildrenHomeworkQuery(
parentId: $user->userId(),
tenantId: $user->tenantId(),
subjectId: is_string($subjectId) && $subjectId !== '' ? $subjectId : null,
));
return new JsonResponse([
'data' => array_map($this->serializeChild(...), $children),
]);
}
/**
* Détail d'un devoir (accessible si l'enfant du parent est dans la classe du devoir).
*/
#[Route('/api/me/children/homework/{id}', name: 'api_parent_child_homework_detail', methods: ['GET'])]
public function homeworkDetail(string $id): JsonResponse
{
$user = $this->getSecurityUser();
$detail = ($this->detailHandler)($user->userId(), $user->tenantId(), $id);
if ($detail === null) {
throw new NotFoundHttpException('Devoir non trouvé.');
}
return new JsonResponse(['data' => $this->serializeDetail($detail)]);
}
/**
* Téléchargement d'une pièce jointe (parent).
*/
#[Route('/api/me/children/homework/{homeworkId}/attachments/{attachmentId}', name: 'api_parent_child_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é.');
}
// Verify parent has a child in the homework's class
$detail = ($this->detailHandler)($user->userId(), $user->tenantId(), $homeworkId);
if ($detail === null) {
throw new NotFoundHttpException('Devoir non trouvé.');
}
$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 getSecurityUser(): SecurityUser
{
$user = $this->security->getUser();
if (!$user instanceof SecurityUser) {
throw new AccessDeniedHttpException('Authentification requise.');
}
return $user;
}
/**
* @return array<string, mixed>
*/
private function serializeChild(ChildHomeworkDto $child): array
{
return [
'childId' => $child->childId,
'firstName' => $child->firstName,
'lastName' => $child->lastName,
'homework' => array_map($this->serializeHomework(...), $child->homework),
];
}
/**
* @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,
),
];
}
/**
* @return array<string, mixed>
*/
private function serializeHomework(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,
];
}
}

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\Repository\HomeworkAttachmentRepository;
use function array_fill_keys;
use function array_map;
use DateTimeImmutable;
use Doctrine\DBAL\ArrayParameterType;
use Doctrine\DBAL\Connection;
use Override;
@@ -33,6 +35,33 @@ final readonly class DoctrineHomeworkAttachmentRepository implements HomeworkAtt
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]
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\HomeworkId;
use App\Scolarite\Domain\Repository\HomeworkAttachmentRepository;
use function array_fill_keys;
use function array_map;
use Override;
final class InMemoryHomeworkAttachmentRepository implements HomeworkAttachmentRepository
@@ -20,6 +24,19 @@ final class InMemoryHomeworkAttachmentRepository implements HomeworkAttachmentRe
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]
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 des enfants par le parent.
*
* @extends Voter<string, null>
*/
final class HomeworkParentVoter extends Voter
{
public const string VIEW = 'HOMEWORK_PARENT_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::PARENT->value, $user->getRoles(), true);
}
}

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,338 @@
<?php
declare(strict_types=1);
namespace App\Tests\Unit\Scolarite\Application\Query\GetChildrenHomework;
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\ParentChildrenReader;
use App\Scolarite\Application\Port\ScheduleDisplayReader;
use App\Scolarite\Application\Port\StudentClassReader;
use App\Scolarite\Application\Query\GetChildrenHomework\GetChildrenHomeworkHandler;
use App\Scolarite\Application\Query\GetChildrenHomework\GetChildrenHomeworkQuery;
use App\Scolarite\Domain\Model\Homework\Homework;
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 GetChildrenHomeworkHandlerTest extends TestCase
{
private const string TENANT_ID = '550e8400-e29b-41d4-a716-446655440001';
private const string PARENT_ID = '550e8400-e29b-41d4-a716-446655440060';
private const string CHILD_A_ID = '550e8400-e29b-41d4-a716-446655440050';
private const string CHILD_B_ID = '550e8400-e29b-41d4-a716-446655440051';
private const string CLASS_A_ID = '550e8400-e29b-41d4-a716-446655440020';
private const string CLASS_B_ID = '550e8400-e29b-41d4-a716-446655440021';
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 itReturnsEmptyWhenParentHasNoChildren(): void
{
$handler = $this->createHandler(children: []);
$result = $handler(new GetChildrenHomeworkQuery(
parentId: self::PARENT_ID,
tenantId: self::TENANT_ID,
));
self::assertSame([], $result);
}
#[Test]
public function itReturnsHomeworkForSingleChild(): void
{
$this->givenHomework(title: 'Exercices chapitre 5', dueDate: '2026-04-15', classId: self::CLASS_A_ID);
$handler = $this->createHandler(
children: [
['studentId' => self::CHILD_A_ID, 'firstName' => 'Emma', 'lastName' => 'Dupont'],
],
classMap: [self::CHILD_A_ID => self::CLASS_A_ID],
);
$result = $handler(new GetChildrenHomeworkQuery(
parentId: self::PARENT_ID,
tenantId: self::TENANT_ID,
));
self::assertCount(1, $result);
self::assertSame(self::CHILD_A_ID, $result[0]->childId);
self::assertSame('Emma', $result[0]->firstName);
self::assertSame('Dupont', $result[0]->lastName);
self::assertCount(1, $result[0]->homework);
self::assertSame('Exercices chapitre 5', $result[0]->homework[0]->title);
}
#[Test]
public function itReturnsHomeworkForMultipleChildren(): void
{
$this->givenHomework(title: 'Maths 6A', dueDate: '2026-04-15', classId: self::CLASS_A_ID);
$this->givenHomework(title: 'Maths 6B', dueDate: '2026-04-16', classId: self::CLASS_B_ID);
$handler = $this->createHandler(
children: [
['studentId' => self::CHILD_A_ID, 'firstName' => 'Emma', 'lastName' => 'Dupont'],
['studentId' => self::CHILD_B_ID, 'firstName' => 'Lucas', 'lastName' => 'Dupont'],
],
classMap: [
self::CHILD_A_ID => self::CLASS_A_ID,
self::CHILD_B_ID => self::CLASS_B_ID,
],
);
$result = $handler(new GetChildrenHomeworkQuery(
parentId: self::PARENT_ID,
tenantId: self::TENANT_ID,
));
self::assertCount(2, $result);
self::assertSame('Emma', $result[0]->firstName);
self::assertCount(1, $result[0]->homework);
self::assertSame('Maths 6A', $result[0]->homework[0]->title);
self::assertSame('Lucas', $result[1]->firstName);
self::assertCount(1, $result[1]->homework);
self::assertSame('Maths 6B', $result[1]->homework[0]->title);
}
#[Test]
public function itFiltersToSpecificChildWhenChildIdProvided(): void
{
$this->givenHomework(title: 'Maths 6A', dueDate: '2026-04-15', classId: self::CLASS_A_ID);
$this->givenHomework(title: 'Maths 6B', dueDate: '2026-04-16', classId: self::CLASS_B_ID);
$handler = $this->createHandler(
children: [
['studentId' => self::CHILD_A_ID, 'firstName' => 'Emma', 'lastName' => 'Dupont'],
['studentId' => self::CHILD_B_ID, 'firstName' => 'Lucas', 'lastName' => 'Dupont'],
],
classMap: [
self::CHILD_A_ID => self::CLASS_A_ID,
self::CHILD_B_ID => self::CLASS_B_ID,
],
);
$result = $handler(new GetChildrenHomeworkQuery(
parentId: self::PARENT_ID,
tenantId: self::TENANT_ID,
childId: self::CHILD_B_ID,
));
self::assertCount(1, $result);
self::assertSame('Lucas', $result[0]->firstName);
self::assertCount(1, $result[0]->homework);
self::assertSame('Maths 6B', $result[0]->homework[0]->title);
}
#[Test]
public function itReturnsEmptyHomeworkWhenChildHasNoClass(): void
{
$handler = $this->createHandler(
children: [
['studentId' => self::CHILD_A_ID, 'firstName' => 'Emma', 'lastName' => 'Dupont'],
],
classMap: [],
);
$result = $handler(new GetChildrenHomeworkQuery(
parentId: self::PARENT_ID,
tenantId: self::TENANT_ID,
));
self::assertCount(1, $result);
self::assertSame('Emma', $result[0]->firstName);
self::assertSame([], $result[0]->homework);
}
#[Test]
public function itReturnsEmptyWhenChildIdDoesNotMatchAnyChild(): void
{
$handler = $this->createHandler(
children: [
['studentId' => self::CHILD_A_ID, 'firstName' => 'Emma', 'lastName' => 'Dupont'],
],
classMap: [self::CHILD_A_ID => self::CLASS_A_ID],
);
$result = $handler(new GetChildrenHomeworkQuery(
parentId: self::PARENT_ID,
tenantId: self::TENANT_ID,
childId: '550e8400-e29b-41d4-a716-446655449999',
));
self::assertSame([], $result);
}
#[Test]
public function itExcludesPastHomework(): void
{
$this->givenHomework(title: 'Passé', dueDate: '2026-01-01', classId: self::CLASS_A_ID);
$this->givenHomework(title: 'Futur', dueDate: '2026-12-15', classId: self::CLASS_A_ID);
$handler = $this->createHandler(
children: [
['studentId' => self::CHILD_A_ID, 'firstName' => 'Emma', 'lastName' => 'Dupont'],
],
classMap: [self::CHILD_A_ID => self::CLASS_A_ID],
);
$result = $handler(new GetChildrenHomeworkQuery(
parentId: self::PARENT_ID,
tenantId: self::TENANT_ID,
));
self::assertCount(1, $result);
self::assertCount(1, $result[0]->homework);
self::assertSame('Futur', $result[0]->homework[0]->title);
}
#[Test]
public function itFiltersBySubjectWhenProvided(): void
{
$this->givenHomework(title: 'Maths', dueDate: '2026-04-15', classId: self::CLASS_A_ID, subjectId: self::SUBJECT_MATH_ID);
$this->givenHomework(title: 'Français', dueDate: '2026-04-16', classId: self::CLASS_A_ID, subjectId: self::SUBJECT_FRENCH_ID);
$handler = $this->createHandler(
children: [
['studentId' => self::CHILD_A_ID, 'firstName' => 'Emma', 'lastName' => 'Dupont'],
],
classMap: [self::CHILD_A_ID => self::CLASS_A_ID],
);
$result = $handler(new GetChildrenHomeworkQuery(
parentId: self::PARENT_ID,
tenantId: self::TENANT_ID,
subjectId: self::SUBJECT_MATH_ID,
));
self::assertCount(1, $result);
self::assertCount(1, $result[0]->homework);
self::assertSame('Maths', $result[0]->homework[0]->title);
}
#[Test]
public function itSortsHomeworkByDueDateAscending(): void
{
$this->givenHomework(title: 'Lointain', dueDate: '2026-05-20', classId: self::CLASS_A_ID);
$this->givenHomework(title: 'Proche', dueDate: '2026-04-10', classId: self::CLASS_A_ID);
$this->givenHomework(title: 'Milieu', dueDate: '2026-04-25', classId: self::CLASS_A_ID);
$handler = $this->createHandler(
children: [
['studentId' => self::CHILD_A_ID, 'firstName' => 'Emma', 'lastName' => 'Dupont'],
],
classMap: [self::CHILD_A_ID => self::CLASS_A_ID],
);
$result = $handler(new GetChildrenHomeworkQuery(
parentId: self::PARENT_ID,
tenantId: self::TENANT_ID,
));
self::assertCount(1, $result);
$titles = array_map(static fn ($hw) => $hw->title, $result[0]->homework);
self::assertSame(['Proche', 'Milieu', 'Lointain'], $titles);
}
/**
* @param array<array{studentId: string, firstName: string, lastName: string}> $children
* @param array<string, string> $classMap studentId => classId
*/
private function createHandler(
array $children = [],
array $classMap = [],
): GetChildrenHomeworkHandler {
$parentChildrenReader = new class($children) implements ParentChildrenReader {
/** @param array<array{studentId: string, firstName: string, lastName: string}> $children */
public function __construct(private readonly array $children)
{
}
public function childrenOf(string $guardianId, TenantId $tenantId): array
{
return $this->children;
}
};
$studentClassReader = new class($classMap) implements StudentClassReader {
/** @param array<string, string> $classMap */
public function __construct(private readonly array $classMap)
{
}
public function currentClassId(string $studentId, TenantId $tenantId): ?string
{
return $this->classMap[$studentId] ?? null;
}
};
$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 GetChildrenHomeworkHandler(
$parentChildrenReader,
$studentClassReader,
$this->homeworkRepository,
$this->attachmentRepository,
$displayReader,
);
}
private function givenHomework(
string $title,
string $dueDate,
string $classId = self::CLASS_A_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,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,479 @@
import { test, expect, type Page } 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 ADMIN_EMAIL = 'e2e-parenthw-admin@example.com';
const ADMIN_PASSWORD = 'AdminParentHW123';
const PARENT_EMAIL = 'e2e-parenthw-parent@example.com';
const PARENT_PASSWORD = 'ParentHomework123';
const TEACHER_EMAIL = 'e2e-parenthw-teacher@example.com';
const TEACHER_PASSWORD = 'TeacherParentHW123';
const STUDENT1_EMAIL = 'e2e-parenthw-student1@example.com';
const STUDENT1_PASSWORD = 'Student1ParentHW123';
const STUDENT2_EMAIL = 'e2e-parenthw-student2@example.com';
const STUDENT2_PASSWORD = 'Student2ParentHW123';
const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
const projectRoot = join(__dirname, '../..');
const composeFile = join(projectRoot, 'compose.yaml');
let student1UserId: string;
let student2UserId: string;
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 extractUserId(output: string): string {
const match = output.match(/User ID\s+([a-f0-9-]{36})/i);
if (!match) {
throw new Error(`Could not extract User ID from command output:\n${output}`);
}
return match[1];
}
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 getTomorrowWeekday(): string {
return getNextWeekday(1);
}
async function loginAsAdmin(page: Page) {
await page.goto(`${ALPHA_URL}/login`);
await page.locator('#email').fill(ADMIN_EMAIL);
await page.locator('#password').fill(ADMIN_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}
async function loginAsParent(page: Page) {
await page.goto(`${ALPHA_URL}/login`);
await page.locator('#email').fill(PARENT_EMAIL);
await page.locator('#password').fill(PARENT_PASSWORD);
await Promise.all([
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
page.getByRole('button', { name: /se connecter/i }).click()
]);
}
async function addGuardianIfNotLinked(page: Page, studentId: string, parentSearchTerm: string, relationship: string) {
await page.goto(`${ALPHA_URL}/admin/students/${studentId}`);
await expect(page.locator('.guardian-section')).toBeVisible({ timeout: 10000 });
await expect(
page.getByText(/aucun parent\/tuteur/i).or(page.locator('.guardian-list'))
).toBeVisible({ timeout: 10000 });
const addButton = page.getByRole('button', { name: /ajouter un parent/i });
if (!(await addButton.isVisible())) return;
const sectionText = await page.locator('.guardian-section').textContent();
if (sectionText && sectionText.includes(parentSearchTerm)) return;
await addButton.click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 5000 });
const searchInput = dialog.getByRole('combobox', { name: /rechercher/i });
await searchInput.fill(parentSearchTerm);
const listbox = dialog.locator('#parent-search-listbox');
await expect(listbox).toBeVisible({ timeout: 10000 });
const option = listbox.locator('[role="option"]').first();
await option.click();
await expect(dialog.getByText(/sélectionné/i)).toBeVisible();
await dialog.getByLabel(/type de relation/i).selectOption(relationship);
await dialog.getByRole('button', { name: 'Ajouter' }).click();
await expect(
page.locator('.alert-success').or(page.locator('.alert-error'))
).toBeVisible({ timeout: 10000 });
}
test.describe('Parent Homework Consultation (Story 5.8)', () => {
test.describe.configure({ mode: 'serial', timeout: 60000 });
const urgentDueDate = getTomorrowWeekday();
const futureDueDate = getNextWeekday(10);
test.beforeAll(async ({ browser }, testInfo) => {
testInfo.setTimeout(120000);
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 users
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${ADMIN_EMAIL} --password=${ADMIN_PASSWORD} --role=ROLE_ADMIN 2>&1`,
{ encoding: 'utf-8' }
);
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${PARENT_EMAIL} --password=${PARENT_PASSWORD} --role=ROLE_PARENT --firstName=ParentHW --lastName=TestUser 2>&1`,
{ encoding: 'utf-8' }
);
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 student1Output = execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${STUDENT1_EMAIL} --password=${STUDENT1_PASSWORD} --role=ROLE_ELEVE --firstName=Emma --lastName=ParentHWTest 2>&1`,
{ encoding: 'utf-8' }
);
student1UserId = extractUserId(student1Output);
const student2Output = execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${STUDENT2_EMAIL} --password=${STUDENT2_PASSWORD} --role=ROLE_ELEVE --firstName=Lucas --lastName=ParentHWTest 2>&1`,
{ encoding: 'utf-8' }
);
student2UserId = extractUserId(student2Output);
const { schoolId, academicYearId } = resolveDeterministicIds();
// Ensure classes exist
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-PHW-6A', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
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-PHW-6B', '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-PHW-Maths', 'E2EPHWMAT', '#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-PHW-Français', 'E2EPHWFRA', '#ef4444', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
} catch {
// May already exist
}
// Assign students to classes
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 = '${STUDENT1_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
`AND c.name = 'E2E-PHW-6A' AND c.tenant_id = '${TENANT_ID}' ` +
`ON CONFLICT DO NOTHING`
);
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 = '${STUDENT2_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
`AND c.name = 'E2E-PHW-6B' AND c.tenant_id = '${TENANT_ID}' ` +
`ON CONFLICT DO NOTHING`
);
// Clean up stale homework from previous runs
try {
runSql(
`DELETE FROM homework WHERE tenant_id = '${TENANT_ID}' AND class_id IN ` +
`(SELECT id FROM school_classes WHERE name IN ('E2E-PHW-6A', 'E2E-PHW-6B') AND tenant_id = '${TENANT_ID}')`
);
} catch {
// Table may not exist
}
// Seed homework for both classes
// Urgent homework (due tomorrow) for class 6A
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, 'Devoir urgent maths', 'Exercices urgents', '${urgentDueDate}', 'published', NOW(), NOW() ` +
`FROM school_classes c, ` +
`(SELECT id FROM subjects WHERE code = 'E2EPHWMAT' 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-PHW-6A' AND c.tenant_id = '${TENANT_ID}'`
);
// Future homework for class 6A
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 français Emma', 'Écrire une rédaction', '${futureDueDate}', 'published', NOW(), NOW() ` +
`FROM school_classes c, ` +
`(SELECT id FROM subjects WHERE code = 'E2EPHWFRA' 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-PHW-6A' AND c.tenant_id = '${TENANT_ID}'`
);
// Homework for class 6B (Lucas)
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 maths Lucas', 'Exercices chapitre 7', '${futureDueDate}', 'published', NOW(), NOW() ` +
`FROM school_classes c, ` +
`(SELECT id FROM subjects WHERE code = 'E2EPHWMAT' 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-PHW-6B' AND c.tenant_id = '${TENANT_ID}'`
);
// Link parent to both students via admin UI
const page = await browser.newPage();
await loginAsAdmin(page);
await addGuardianIfNotLinked(page, student1UserId, PARENT_EMAIL, 'tuteur');
await addGuardianIfNotLinked(page, student2UserId, PARENT_EMAIL, 'tutrice');
await page.close();
clearCache();
});
// ======================================================================
// AC1: Liste devoirs enfant
// ======================================================================
test.describe('AC1: Homework List', () => {
test('parent can navigate to homework page via navigation', async ({ page }) => {
await loginAsParent(page);
const nav = page.locator('.desktop-nav');
await expect(nav.getByRole('link', { name: /devoirs/i })).toBeVisible({ timeout: 15000 });
});
test('parent homework page shows homework list', async ({ page }) => {
await loginAsParent(page);
await page.goto(`${ALPHA_URL}/dashboard/parent-homework`);
await expect(
page.getByRole('heading', { name: /devoirs des enfants/i })
).toBeVisible({ timeout: 15000 });
// Homework cards should be visible
const cards = page.locator('.homework-card');
await expect(cards.first()).toBeVisible({ timeout: 10000 });
});
});
// ======================================================================
// AC2: Vue identique élève (sans marquage "Fait")
// ======================================================================
test.describe('AC2: Student-like View Without Done Toggle', () => {
test('homework cards do NOT show done toggle checkbox', async ({ page }) => {
await loginAsParent(page);
await page.goto(`${ALPHA_URL}/dashboard/parent-homework`);
const card = page.locator('.homework-card').first();
await expect(card).toBeVisible({ timeout: 10000 });
// No toggle-done button should exist (privacy)
await expect(card.locator('.toggle-done')).toHaveCount(0);
});
test('homework cards show title and due date', async ({ page }) => {
await loginAsParent(page);
await page.goto(`${ALPHA_URL}/dashboard/parent-homework`);
const card = page.locator('.homework-card').first();
await expect(card).toBeVisible({ timeout: 10000 });
// Title visible
await expect(card.locator('.card-title')).toBeVisible();
// Due date visible
await expect(card.locator('.due-date')).toBeVisible();
});
});
// ======================================================================
// AC3: Vue multi-enfants
// ======================================================================
test.describe('AC3: Multi-Child View', () => {
test('parent with multiple children sees child selector', async ({ page }) => {
await loginAsParent(page);
await page.goto(`${ALPHA_URL}/dashboard/parent-homework`);
const childSelector = page.locator('.child-selector');
await expect(childSelector).toBeVisible({ timeout: 10000 });
// Should have "Tous" + 2 children buttons
const buttons = childSelector.locator('.child-button');
await expect(buttons).toHaveCount(3);
});
test('consolidated view shows homework grouped by child', async ({ page }) => {
await loginAsParent(page);
await page.goto(`${ALPHA_URL}/dashboard/parent-homework`);
// Wait for data to load
const card = page.locator('[data-testid="homework-card"]').first();
await expect(card).toBeVisible({ timeout: 10000 });
// Both children's names should appear as section headers
const childNames = page.locator('[data-testid="child-name"]');
await expect(childNames).toHaveCount(2, { timeout: 10000 });
});
test('clicking a specific child filters to their homework', async ({ page }) => {
await loginAsParent(page);
await page.goto(`${ALPHA_URL}/dashboard/parent-homework`);
const childSelector = page.locator('.child-selector');
await expect(childSelector).toBeVisible({ timeout: 10000 });
// Click on first child (Emma)
const buttons = childSelector.locator('.child-button');
await buttons.nth(1).click();
// Wait for data to reload
const card = page.locator('.homework-card').first();
await expect(card).toBeVisible({ timeout: 10000 });
// Should no longer show multiple child sections
await expect(page.locator('.child-name')).toHaveCount(0, { timeout: 5000 });
});
});
// ======================================================================
// AC4: Mise en évidence urgence
// ======================================================================
test.describe('AC4: Urgency Highlight', () => {
test('homework due tomorrow shows urgent badge', async ({ page }) => {
await loginAsParent(page);
await page.goto(`${ALPHA_URL}/dashboard/parent-homework`);
await expect(page.locator('[data-testid="homework-card"]').first()).toBeVisible({ timeout: 10000 });
// Find urgent badge — text depends on when test runs relative to seeded date
const urgentBadge = page.locator('[data-testid="urgent-badge"]');
await expect(urgentBadge.first()).toBeVisible({ timeout: 5000 });
await expect(urgentBadge.first()).toContainText(/pour demain|aujourd'hui|en retard/i);
});
test('urgent homework card has red styling', async ({ page }) => {
await loginAsParent(page);
await page.goto(`${ALPHA_URL}/dashboard/parent-homework`);
await expect(page.locator('[data-testid="homework-card"]').first()).toBeVisible({ timeout: 10000 });
// Urgent card should have the urgent class
const urgentCard = page.locator('[data-testid="homework-card"].urgent');
await expect(urgentCard.first()).toBeVisible({ timeout: 5000 });
});
test('urgent homework shows contact teacher link', async ({ page }) => {
await loginAsParent(page);
await page.goto(`${ALPHA_URL}/dashboard/parent-homework`);
await expect(page.locator('[data-testid="homework-card"]').first()).toBeVisible({ timeout: 10000 });
// Contact teacher link should be visible on urgent homework
const contactLink = page.locator('[data-testid="contact-teacher"]');
await expect(contactLink.first()).toBeVisible({ timeout: 5000 });
await expect(contactLink.first()).toContainText(/contacter l'enseignant/i);
});
test('contact teacher link points to messaging page', async ({ page }) => {
await loginAsParent(page);
await page.goto(`${ALPHA_URL}/dashboard/parent-homework`);
await expect(page.locator('[data-testid="homework-card"]').first()).toBeVisible({ timeout: 10000 });
const contactLink = page.locator('[data-testid="contact-teacher"]').first();
await expect(contactLink).toBeVisible({ timeout: 5000 });
// Verify href contains message creation path with proper encoding
const href = await contactLink.getAttribute('href');
expect(href).toContain('/messages/new');
expect(href).toContain('to=');
expect(href).toContain('subject=Devoir');
});
});
// ======================================================================
// Homework detail
// ======================================================================
test.describe('Homework Detail', () => {
test('clicking a homework card shows detail view', async ({ page }) => {
await loginAsParent(page);
await page.goto(`${ALPHA_URL}/dashboard/parent-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('.detail-title')).toBeVisible();
});
test('back button returns to homework list', async ({ page }) => {
await loginAsParent(page);
await page.goto(`${ALPHA_URL}/dashboard/parent-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 });
});
});
});

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,7 +1,9 @@
<script lang="ts">
import type { DemoData } from '$types';
import type { ScheduleSlot } from '$lib/features/schedule/api/schedule';
import type { StudentHomework } from '$lib/features/homework/api/studentHomework';
import { fetchChildDaySchedule } from '$lib/features/schedule/api/parentSchedule';
import { fetchChildrenHomework } from '$lib/features/homework/api/parentHomework';
import { recordSync } from '$lib/features/schedule/stores/scheduleCache.svelte';
import SerenityScorePreview from '$lib/components/molecules/SerenityScore/SerenityScorePreview.svelte';
import SerenityScoreExplainer from '$lib/components/molecules/SerenityScore/SerenityScoreExplainer.svelte';
@@ -69,13 +71,41 @@
}
}
// Load schedule when selectedChildId changes
// Homework widget state
let parentHomeworks = $state<StudentHomework[]>([]);
let homeworkLoading = $state(false);
async function loadHomeworks() {
homeworkLoading = true;
try {
const children = await fetchChildrenHomework();
parentHomeworks = children.flatMap((c) => c.homework);
} catch {
// Silently fail on dashboard widget
} finally {
homeworkLoading = false;
}
}
function formatShortDate(dateStr: string): string {
const date = new Date(dateStr + 'T00:00:00');
return date.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' });
}
// Load schedule and homework when selectedChildId changes
$effect(() => {
if (isParent && selectedChildId) {
loadChildSchedule(selectedChildId);
}
});
$effect(() => {
if (isParent) {
loadHomeworks();
}
});
let showExplainer = $state(false);
const isDemo = $derived(!hasRealData);
@@ -181,10 +211,29 @@
<!-- Devoirs Section -->
<DashboardSection
title="Devoirs à venir"
isPlaceholder={!hasRealData}
isPlaceholder={!isParent && !hasRealData}
placeholderMessage="Les devoirs seront affichés ici une fois assignés"
>
{#if hasRealData}
{#if isParent}
{#if homeworkLoading}
<SkeletonList items={3} message="Chargement des devoirs..." />
{:else if parentHomeworks.length === 0}
<p class="empty-homework">Aucun devoir à venir</p>
{:else}
<ul class="homework-list">
{#each parentHomeworks.slice(0, 5) as homework}
<li class="homework-item" style:border-left-color={homework.subjectColor ?? '#3b82f6'}>
<span class="homework-subject" style:color={homework.subjectColor ?? '#3b82f6'}>{homework.subjectName}</span>
<span class="homework-title">{homework.title}</span>
<span class="homework-due">Pour le {formatShortDate(homework.dueDate)}</span>
</li>
{/each}
</ul>
<a href="/dashboard/parent-homework" class="view-all-link">
Voir tous les devoirs →
</a>
{/if}
{:else if hasRealData}
{#if isLoading}
<SkeletonList items={3} message="Chargement des devoirs..." />
{:else}
@@ -416,4 +465,27 @@
background: #fee2e2;
color: #991b1b;
}
.view-all-link {
display: block;
text-align: center;
margin-top: 0.5rem;
padding: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
color: #3b82f6;
text-decoration: none;
}
.view-all-link:hover {
color: #2563eb;
text-decoration: underline;
}
.empty-homework {
text-align: center;
padding: 1rem;
color: #6b7280;
font-size: 0.875rem;
}
</style>

View File

@@ -1,8 +1,12 @@
<script lang="ts">
import type { DemoData } from '$types';
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 { 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 { getHomeworkStatuses } from '$lib/features/homework/stores/homeworkStatus.svelte';
import DashboardSection from '$lib/components/molecules/DashboardSection.svelte';
import SkeletonList from '$lib/components/atoms/Skeleton/SkeletonList.svelte';
import ScheduleWidget from '$lib/components/organisms/StudentSchedule/ScheduleWidget.svelte';
@@ -28,6 +32,16 @@
let scheduleLoading = $state(false);
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 {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
@@ -35,6 +49,11 @@
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() {
scheduleLoading = true;
scheduleError = null;
@@ -57,9 +76,47 @@
}
}
if (isEleve) {
loadTodaySchedule();
async function loadHomeworks() {
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>
<div class="dashboard-student">
@@ -148,11 +205,34 @@
<!-- Devoirs Section -->
<DashboardSection
title="Mes devoirs"
subtitle={hasRealData ? "À faire" : undefined}
isPlaceholder={!hasRealData}
subtitle={isEleve ? "À faire" : (hasRealData ? "À faire" : undefined)}
isPlaceholder={!isEleve && !hasRealData}
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}
<SkeletonList items={3} message="Chargement des devoirs..." />
{:else}
@@ -178,6 +258,16 @@
</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>
.dashboard-student {
display: flex;
@@ -327,10 +417,21 @@
}
.homework-item {
display: block;
width: 100%;
padding: 0.75rem;
background: #f9fafb;
border: none;
border-radius: 0.5rem;
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 {
@@ -380,4 +481,67 @@
font-size: 0.875rem;
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>

View File

@@ -0,0 +1,455 @@
<script lang="ts">
import type { ChildHomework } from '$lib/features/homework/api/parentHomework';
import type { StudentHomework, StudentHomeworkDetail as HomeworkDetailType } from '$lib/features/homework/api/studentHomework';
import { fetchChildrenHomework, fetchChildHomework, fetchParentHomeworkDetail, getParentAttachmentUrl } from '$lib/features/homework/api/parentHomework';
import { isOffline } from '$lib/utils/network';
import { cacheParentHomework, getCachedParentHomework } from '$lib/features/homework/stores/parentHomeworkCache.svelte';
import ChildSelector from '$lib/components/organisms/ChildSelector/ChildSelector.svelte';
import HomeworkDetail from '$lib/components/organisms/StudentHomework/HomeworkDetail.svelte';
import SkeletonList from '$lib/components/atoms/Skeleton/SkeletonList.svelte';
let childrenData = $state<ChildHomework[]>([]);
let selectedChildId = $state<string | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
let allSubjects = $state<{ id: string; name: string; color: string | null }[]>([]);
let selectedSubjectId = $state<string | null>(null);
let selectedDetail = $state<HomeworkDetailType | null>(null);
let detailLoading = $state(false);
let allHomework = $derived(
childrenData.flatMap((child) =>
child.homework.map((hw) => ({ ...hw, childName: `${child.firstName} ${child.lastName}` }))
)
);
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());
}
type UrgencyLevel = 'overdue' | 'today' | 'tomorrow';
function urgencyLevel(dueDate: string): UrgencyLevel | null {
const now = new Date();
const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
const tom = new Date(now);
tom.setDate(tom.getDate() + 1);
const tomorrowStr = `${tom.getFullYear()}-${String(tom.getMonth() + 1).padStart(2, '0')}-${String(tom.getDate()).padStart(2, '0')}`;
if (dueDate < todayStr) return 'overdue';
if (dueDate === todayStr) return 'today';
if (dueDate === tomorrowStr) return 'tomorrow';
return null;
}
function urgencyLabel(level: UrgencyLevel): string {
switch (level) {
case 'overdue': return 'En retard';
case 'today': return "Aujourd'hui";
case 'tomorrow': return 'Pour demain';
}
}
function formatDueDate(dateStr: string): string {
const date = new Date(dateStr + 'T00:00:00');
return date.toLocaleDateString('fr-FR', {
weekday: 'short',
day: 'numeric',
month: 'long'
});
}
async function loadHomework() {
loading = true;
error = null;
try {
if (selectedChildId) {
const child = await fetchChildHomework(selectedChildId, selectedSubjectId ?? undefined);
childrenData = [child];
} else {
childrenData = await fetchChildrenHomework(selectedSubjectId ?? undefined);
}
cacheParentHomework(childrenData);
if (selectedSubjectId === null) {
const all = childrenData.flatMap((c) => c.homework);
allSubjects = extractSubjects(all);
}
} catch (e) {
const cached = getCachedParentHomework();
if (cached) {
childrenData = cached;
if (selectedSubjectId === null) {
allSubjects = extractSubjects(cached.flatMap((c) => c.homework));
}
} else {
error = e instanceof Error ? e.message : 'Erreur de chargement';
}
} finally {
loading = false;
}
}
function handleChildSelected(childId: string | null) {
selectedChildId = childId;
selectedSubjectId = null;
loadHomework();
}
function handleFilterChange(subjectId: string | null) {
selectedSubjectId = subjectId;
loadHomework();
}
async function handleCardClick(homeworkId: string) {
detailLoading = true;
try {
selectedDetail = await fetchParentHomeworkDetail(homeworkId);
} catch {
// Stay on list if detail fails
} finally {
detailLoading = false;
}
}
function handleBack() {
selectedDetail = null;
}
$effect(() => {
loadHomework();
});
</script>
{#snippet homeworkCard(hw: StudentHomework)}
{@const level = urgencyLevel(hw.dueDate)}
<div
class="homework-card"
class:urgent={level !== null}
data-testid="homework-card"
style:border-left-color={hw.subjectColor ?? '#3b82f6'}
role="button"
tabindex="0"
onclick={() => handleCardClick(hw.id)}
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleCardClick(hw.id); } }}
>
<div class="card-header">
<span class="subject-name" style:color={hw.subjectColor ?? '#3b82f6'}>{hw.subjectName}</span>
{#if level}
<span class="urgent-badge" class:overdue={level === 'overdue'} data-testid="urgent-badge">
{urgencyLabel(level)}
</span>
{/if}
</div>
<h4 class="card-title">{hw.title}</h4>
<div class="card-footer">
<span class="due-date">Pour le {formatDueDate(hw.dueDate)}</span>
{#if hw.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}
{#if level}
<a
href="/messages/new?to={hw.teacherId}&subject={encodeURIComponent('Devoir: ' + hw.title)}"
class="contact-teacher"
data-testid="contact-teacher"
onclick={(e) => e.stopPropagation()}
>
Contacter l'enseignant
</a>
{/if}
</div>
</div>
{/snippet}
{#if selectedDetail}
<HomeworkDetail detail={selectedDetail} onBack={handleBack} getAttachmentUrl={getParentAttachmentUrl} />
{:else}
<div class="parent-homework">
<ChildSelector onChildSelected={handleChildSelected} />
{#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 loadHomework()}>Réessayer</button>
</div>
{:else if allHomework.length === 0}
<div class="empty-state">
<p>Aucun devoir pour le moment</p>
</div>
{:else}
{#if childrenData.length > 1}
{#each childrenData as child (child.childId)}
{#if child.homework.length > 0}
<section class="child-section">
<h3 class="child-name" data-testid="child-name">{child.firstName} {child.lastName}</h3>
<ul class="homework-list" role="list">
{#each child.homework as hw (hw.id)}
<li>{@render homeworkCard(hw)}</li>
{/each}
</ul>
</section>
{/if}
{/each}
{:else}
<ul class="homework-list" role="list">
{#each allHomework as hw (hw.id)}
<li>{@render homeworkCard(hw)}</li>
{/each}
</ul>
{/if}
{/if}
{#if detailLoading}
<div class="detail-loading-overlay" role="status">
<p>Chargement...</p>
</div>
{/if}
</div>
{/if}
<style>
.parent-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);
}
.child-section {
margin-bottom: 0.5rem;
}
.child-name {
margin: 0 0 0.5rem;
font-size: 1rem;
font-weight: 600;
color: #1e40af;
}
.homework-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.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.urgent {
border-left-color: #ef4444;
background: #fef2f2;
}
.homework-card.urgent:hover {
background: #fee2e2;
}
.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;
}
.urgent-badge {
font-size: 0.6875rem;
font-weight: 700;
color: #991b1b;
background: #fecaca;
border-radius: 999px;
padding: 0.125rem 0.5rem;
text-transform: uppercase;
}
.urgent-badge.overdue {
color: #7f1d1d;
background: #fca5a5;
}
.card-title {
margin: 0 0 0.25rem;
font-size: 0.9375rem;
font-weight: 500;
color: #1f2937;
}
.card-footer {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.due-date {
font-size: 0.8125rem;
color: #6b7280;
}
.attachment-indicator {
color: #6b7280;
display: flex;
align-items: center;
}
.contact-teacher {
margin-left: auto;
font-size: 0.75rem;
font-weight: 600;
color: #3b82f6;
text-decoration: none;
padding: 0.25rem 0.5rem;
border: 1px solid #bfdbfe;
border-radius: 0.375rem;
background: white;
transition: background 0.15s;
}
.contact-teacher:hover {
background: #eff6ff;
}
.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,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,240 @@
<script lang="ts">
import type { StudentHomeworkDetail } from '$lib/features/homework/api/studentHomework';
import { getAttachmentUrl as defaultGetAttachmentUrl } from '$lib/features/homework/api/studentHomework';
import { authenticatedFetch } from '$lib/auth';
let {
detail,
onBack,
getAttachmentUrl = defaultGetAttachmentUrl
}: {
detail: StudentHomeworkDetail;
onBack: () => void;
getAttachmentUrl?: (homeworkId: string, attachmentId: string) => string;
} = $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,65 @@
import { getApiBaseUrl } from '$lib/api';
import { authenticatedFetch } from '$lib/auth';
import type { StudentHomework, StudentHomeworkDetail } from './studentHomework';
export interface ChildHomework {
childId: string;
firstName: string;
lastName: string;
homework: StudentHomework[];
}
/**
* Récupère les devoirs de tous les enfants du parent connecté.
*/
export async function fetchChildrenHomework(subjectId?: string): Promise<ChildHomework[]> {
const apiUrl = getApiBaseUrl();
const params = subjectId ? `?subjectId=${encodeURIComponent(subjectId)}` : '';
const response = await authenticatedFetch(`${apiUrl}/me/children/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 les devoirs d'un enfant spécifique.
*/
export async function fetchChildHomework(childId: string, subjectId?: string): Promise<ChildHomework> {
const apiUrl = getApiBaseUrl();
const params = subjectId ? `?subjectId=${encodeURIComponent(subjectId)}` : '';
const response = await authenticatedFetch(`${apiUrl}/me/children/${childId}/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 via l'endpoint parent.
*/
/**
* Retourne l'URL de téléchargement d'une pièce jointe (endpoint parent).
*/
export function getParentAttachmentUrl(homeworkId: string, attachmentId: string): string {
const apiUrl = getApiBaseUrl();
return `${apiUrl}/me/children/homework/${homeworkId}/attachments/${attachmentId}`;
}
export async function fetchParentHomeworkDetail(homeworkId: string): Promise<StudentHomeworkDetail> {
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(`${apiUrl}/me/children/homework/${homeworkId}`);
if (!response.ok) {
throw new Error(`Erreur lors du chargement du devoir (${response.status})`);
}
const json = await response.json();
return json.data;
}

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,63 @@
import { browser } from '$app/environment';
import { getCurrentUserId } from '$lib/auth/auth.svelte';
import type { ChildHomework } from '$lib/features/homework/api/parentHomework';
const CACHE_PREFIX = 'classeo:parent-homework:cache:';
const MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24h
interface CacheEntry {
data: ChildHomework[];
timestamp: number;
}
function cacheKey(): string | null {
const userId = getCurrentUserId();
return userId ? `${CACHE_PREFIX}${userId}` : null;
}
/**
* Sauvegarde les devoirs en cache localStorage, scopé par utilisateur.
*/
export function cacheParentHomework(data: ChildHomework[]): void {
if (!browser) return;
const key = cacheKey();
if (!key) return;
const entry: CacheEntry = {
data,
timestamp: Date.now(),
};
try {
localStorage.setItem(key, JSON.stringify(entry));
} catch {
// localStorage full — ignore silently
}
}
/**
* Récupère les devoirs depuis le cache si encore valides.
*/
export function getCachedParentHomework(): ChildHomework[] | null {
if (!browser) return null;
const key = cacheKey();
if (!key) return null;
try {
const raw = localStorage.getItem(key);
if (!raw) return null;
const entry: CacheEntry = JSON.parse(raw);
if (Date.now() - entry.timestamp > MAX_AGE_MS) {
localStorage.removeItem(key);
return null;
}
return entry.data;
} catch {
return null;
}
}

View File

@@ -1,19 +1,13 @@
import { browser } from '$app/environment';
export { isOffline } from '$lib/utils/network';
const LAST_SYNC_KEY = 'classeo:schedule:lastSync';
let lastSyncValue = $state<string | null>(
browser ? localStorage.getItem(LAST_SYNC_KEY) : null
);
/**
* Vérifie si le navigateur est actuellement hors ligne.
*/
export function isOffline(): boolean {
if (!browser) return false;
return !navigator.onLine;
}
/**
* Enregistre la date de dernière synchronisation de l'EDT.
*/

View File

@@ -0,0 +1,9 @@
import { browser } from '$app/environment';
/**
* Vérifie si le navigateur est actuellement hors ligne.
*/
export function isOffline(): boolean {
if (!browser) return false;
return !navigator.onLine;
}

View File

@@ -111,6 +111,7 @@
{/if}
{#if isParent}
<a href="/dashboard/parent-schedule" class="nav-link" class:active={pathname === '/dashboard/parent-schedule'}>EDT enfants</a>
<a href="/dashboard/parent-homework" class="nav-link" class:active={pathname === '/dashboard/parent-homework'}>Devoirs</a>
{/if}
<button class="nav-button" onclick={goSettings}>Paramètres</button>
<button class="logout-button" onclick={handleLogout} disabled={isLoggingOut}>
@@ -163,6 +164,9 @@
<a href="/dashboard/parent-schedule" class="mobile-nav-link" class:active={pathname === '/dashboard/parent-schedule'}>
EDT enfants
</a>
<a href="/dashboard/parent-homework" class="mobile-nav-link" class:active={pathname === '/dashboard/parent-homework'}>
Devoirs
</a>
{/if}
<button class="mobile-nav-link" onclick={goSettings}>Paramètres</button>
</div>

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

@@ -0,0 +1,29 @@
<script lang="ts">
import ParentHomeworkView from '$lib/components/organisms/ParentHomework/ParentHomeworkView.svelte';
</script>
<svelte:head>
<title>Devoirs des enfants - Classeo</title>
</svelte:head>
<div class="homework-page">
<header class="page-header">
<h1>Devoirs des enfants</h1>
</header>
<ParentHomeworkView />
</div>
<style>
.homework-page {
display: flex;
flex-direction: column;
gap: 1rem;
}
.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: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,webp,woff,woff2}'],
runtimeCaching: [
{
urlPattern: /\/api\/me\/schedule\//,
handler: 'NetworkFirst',
options: {
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg,webp,woff,woff2}'],
runtimeCaching: [
{
urlPattern: /\/api\/me\/homework(?:\?.*)?$/,
handler: 'NetworkFirst',
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',
expiration: {
maxEntries: 90,