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.
This commit is contained in:
2026-03-23 00:34:55 +01:00
parent 2e2328c6ca
commit 8d950b0f3c
18 changed files with 2689 additions and 13 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,133 @@
<?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 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);
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,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,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,285 @@
<?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\GetChildrenHomeworkDetailHandler;
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 GetChildrenHomeworkDetailHandlerTest 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 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 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 itReturnsNullWhenHomeworkDoesNotExist(): 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(
self::PARENT_ID,
self::TENANT_ID,
'550e8400-e29b-41d4-a716-446655449999',
);
self::assertNull($result);
}
#[Test]
public function itReturnsNullWhenParentHasNoChildInHomeworkClass(): void
{
$homework = $this->givenHomework(
title: 'Exercices chapitre 5',
dueDate: '2026-04-15',
classId: self::CLASS_B_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(
self::PARENT_ID,
self::TENANT_ID,
(string) $homework->id,
);
self::assertNull($result);
}
#[Test]
public function itReturnsDetailWhenParentHasChildInClass(): void
{
$homework = $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(
self::PARENT_ID,
self::TENANT_ID,
(string) $homework->id,
);
self::assertNotNull($result);
self::assertSame((string) $homework->id, $result->id);
self::assertSame('Exercices chapitre 5', $result->title);
self::assertSame('2026-04-15', $result->dueDate);
self::assertSame([], $result->attachments);
}
#[Test]
public function itReturnsDetailWithAttachments(): void
{
$homework = $this->givenHomework(
title: 'Devoir avec pièces jointes',
dueDate: '2026-04-20',
classId: self::CLASS_A_ID,
);
$attachment = new HomeworkAttachment(
id: HomeworkAttachmentId::generate(),
filename: 'exercice.pdf',
filePath: '/uploads/exercice.pdf',
fileSize: 2048,
mimeType: 'application/pdf',
uploadedAt: new DateTimeImmutable('2026-03-12 10:00:00'),
);
$this->attachmentRepository->save($homework->id, $attachment);
$handler = $this->createHandler(
children: [
['studentId' => self::CHILD_A_ID, 'firstName' => 'Emma', 'lastName' => 'Dupont'],
],
classMap: [self::CHILD_A_ID => self::CLASS_A_ID],
);
$result = $handler(
self::PARENT_ID,
self::TENANT_ID,
(string) $homework->id,
);
self::assertNotNull($result);
self::assertCount(1, $result->attachments);
self::assertSame((string) $attachment->id, $result->attachments[0]->id);
self::assertSame('exercice.pdf', $result->attachments[0]->filename);
self::assertSame(2048, $result->attachments[0]->fileSize);
self::assertSame('application/pdf', $result->attachments[0]->mimeType);
}
#[Test]
public function itReturnsCorrectSubjectAndTeacherInfo(): void
{
$homework = $this->givenHomework(
title: 'Révisions',
dueDate: '2026-04-18',
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],
subjectName: 'Physique-Chimie',
subjectColor: '#ef4444',
teacherName: 'Marie Martin',
);
$result = $handler(
self::PARENT_ID,
self::TENANT_ID,
(string) $homework->id,
);
self::assertNotNull($result);
self::assertSame(self::SUBJECT_MATH_ID, $result->subjectId);
self::assertSame('Physique-Chimie', $result->subjectName);
self::assertSame('#ef4444', $result->subjectColor);
self::assertSame(self::TEACHER_ID, $result->teacherId);
self::assertSame('Marie Martin', $result->teacherName);
}
/**
* @param array<array{studentId: string, firstName: string, lastName: string}> $children
* @param array<string, string> $classMap studentId => classId
*/
private function createHandler(
array $children = [],
array $classMap = [],
string $subjectName = 'Mathématiques',
?string $subjectColor = '#3b82f6',
string $teacherName = 'Jean Dupont',
): GetChildrenHomeworkDetailHandler {
$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($subjectName, $subjectColor, $teacherName) implements ScheduleDisplayReader {
public function __construct(
private readonly string $subjectName,
private readonly ?string $subjectColor,
private readonly string $teacherName,
) {
}
public function subjectDisplay(string $tenantId, string ...$subjectIds): array
{
$map = [];
foreach ($subjectIds as $id) {
$map[$id] = ['name' => $this->subjectName, 'color' => $this->subjectColor];
}
return $map;
}
public function teacherNames(string $tenantId, string ...$teacherIds): array
{
$map = [];
foreach ($teacherIds as $id) {
$map[$id] = $this->teacherName;
}
return $map;
}
};
return new GetChildrenHomeworkDetailHandler(
$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,315 @@
<?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 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,750 @@
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);
}
function getFormattedToday(): string {
const date = new Date();
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
function getPastDate(daysAgo: number): string {
const date = new Date();
date.setDate(date.getDate() - daysAgo);
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
async function 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);
const todayDueDate = getFormattedToday();
const overdueDueDate = getPastDate(3);
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}'`
);
// Homework due TODAY for class 6A ("Aujourd'hui" badge)
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 maths aujourd''hui', 'Exercices pour aujourd''hui', '${todayDueDate}', '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}'`
);
// Overdue homework for class 6A ("En retard" badge)
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 français en retard', 'Exercices en retard', '${overdueDueDate}', '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}'`
);
// 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 });
});
});
// ======================================================================
// AC4 Extended: Urgency Badge Variants
// ======================================================================
test.describe('AC4 Extended: Urgency Badge Variants', () => {
test('homework due today shows "Aujourd\'hui" 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 the card for "Devoir maths aujourd'hui"
const todayCard = page.locator('[data-testid="homework-card"]', {
has: page.locator('.card-title', { hasText: "Devoir maths aujourd'hui" })
});
await expect(todayCard).toBeVisible({ timeout: 10000 });
// Verify urgent badge shows "Aujourd'hui"
const badge = todayCard.locator('[data-testid="urgent-badge"]');
await expect(badge).toBeVisible({ timeout: 5000 });
await expect(badge).toContainText("Aujourd'hui");
// Badge should NOT have the overdue class
await expect(badge).not.toHaveClass(/overdue/);
});
test('overdue homework shows "En retard" badge with overdue 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 });
// Find the card for the overdue homework
const overdueCard = page.locator('[data-testid="homework-card"]', {
has: page.locator('.card-title', { hasText: 'Devoir français en retard' })
});
await expect(overdueCard).toBeVisible({ timeout: 10000 });
// Verify urgent badge shows "En retard"
const badge = overdueCard.locator('[data-testid="urgent-badge"]');
await expect(badge).toBeVisible({ timeout: 5000 });
await expect(badge).toContainText('En retard');
// Badge should have the overdue class for stronger styling
await expect(badge).toHaveClass(/overdue/);
});
test('overdue homework card has urgent 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 });
// The overdue card should also have the .urgent class
const overdueCard = page.locator('[data-testid="homework-card"]', {
has: page.locator('.card-title', { hasText: 'Devoir français en retard' })
});
await expect(overdueCard).toHaveClass(/urgent/);
});
});
// ======================================================================
// AC1 Extended: Subject Filter
// ======================================================================
test.describe('AC1 Extended: Subject Filter', () => {
test('subject filter chips are visible when multiple subjects exist', 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 });
// Filter bar should be visible
const filterBar = page.locator('.filter-bar');
await expect(filterBar).toBeVisible({ timeout: 5000 });
// Should have "Tous" chip + subject chips (at least Maths and Français)
const chips = filterBar.locator('.filter-chip');
const chipCount = await chips.count();
expect(chipCount).toBeGreaterThanOrEqual(3); // Tous + Maths + Français
// "Tous" chip should be active by default
const tousChip = filterBar.locator('.filter-chip', { hasText: 'Tous' });
await expect(tousChip).toBeVisible();
await expect(tousChip).toHaveClass(/active/);
});
test('clicking a subject filter shows only homework of that subject', 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 });
// Count all homework cards before filtering
const allCardsCount = await page.locator('[data-testid="homework-card"]').count();
expect(allCardsCount).toBeGreaterThanOrEqual(2);
// Click on the "E2E-PHW-Français" filter chip
const filterBar = page.locator('.filter-bar');
const francaisChip = filterBar.locator('.filter-chip', { hasText: /Français/i });
await expect(francaisChip).toBeVisible({ timeout: 5000 });
await francaisChip.click();
// Wait for the filter to be applied (chip becomes active)
await expect(francaisChip).toHaveClass(/active/, { timeout: 5000 });
// Wait for homework cards to update
await expect(page.locator('[data-testid="homework-card"]').first()).toBeVisible({ timeout: 10000 });
// All visible cards should be Français homework
const filteredCards = page.locator('[data-testid="homework-card"]');
const filteredCount = await filteredCards.count();
expect(filteredCount).toBeLessThan(allCardsCount);
// Each visible card should show the Français subject name
for (let i = 0; i < filteredCount; i++) {
await expect(filteredCards.nth(i).locator('.subject-name')).toContainText(/Français/i);
}
});
test('clicking "Tous" resets the subject filter', 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 });
// Apply a filter first
const filterBar = page.locator('.filter-bar');
const francaisChip = filterBar.locator('.filter-chip', { hasText: /Français/i });
await francaisChip.click();
await expect(francaisChip).toHaveClass(/active/, { timeout: 5000 });
await expect(page.locator('[data-testid="homework-card"]').first()).toBeVisible({ timeout: 10000 });
const filteredCount = await page.locator('[data-testid="homework-card"]').count();
// Now click "Tous" to reset
const tousChip = filterBar.locator('.filter-chip', { hasText: 'Tous' });
await tousChip.click();
await expect(tousChip).toHaveClass(/active/, { timeout: 5000 });
await expect(page.locator('[data-testid="homework-card"]').first()).toBeVisible({ timeout: 10000 });
// Card count should be greater than filtered count
const resetCount = await page.locator('[data-testid="homework-card"]').count();
expect(resetCount).toBeGreaterThan(filteredCount);
});
});
// ======================================================================
// Dashboard Widget
// ======================================================================
test.describe('Dashboard Widget', () => {
test('dashboard shows homework widget with child names', async ({ page }) => {
await loginAsParent(page);
// Dashboard should load
await expect(page.locator('.dashboard-grid')).toBeVisible({ timeout: 15000 });
// Homework section should show items
const homeworkSection = page.locator('.dashboard-grid').locator('section', { hasText: /devoirs à venir/i });
await expect(homeworkSection).toBeVisible({ timeout: 10000 });
// Items should be clickable buttons
const homeworkBtn = homeworkSection.locator('button.homework-item').first();
await expect(homeworkBtn).toBeVisible({ timeout: 10000 });
// Child name should be visible on items
await expect(homeworkSection.locator('.homework-child').first()).toBeVisible();
});
test('dashboard shows homework from multiple children sorted by date', async ({ page }) => {
await loginAsParent(page);
await expect(page.locator('.dashboard-grid')).toBeVisible({ timeout: 15000 });
// Wait for homework buttons to load
const homeworkBtn = page.locator('button.homework-item').first();
await expect(homeworkBtn).toBeVisible({ timeout: 10000 });
// Should see homework from both Emma and Lucas
const childLabels = page.locator('.homework-child');
const count = await childLabels.count();
const names = new Set<string>();
for (let i = 0; i < count; i++) {
const text = await childLabels.nth(i).textContent();
if (text) names.add(text.trim());
}
expect(names.size).toBeGreaterThanOrEqual(2);
});
test('clicking a homework item opens detail modal', async ({ page }) => {
await loginAsParent(page);
await expect(page.locator('.dashboard-grid')).toBeVisible({ timeout: 15000 });
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 loginAsParent(page);
await expect(page.locator('.dashboard-grid')).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 });
});
test('"Voir tous les devoirs" link navigates to homework page', async ({ page }) => {
await loginAsParent(page);
await expect(page.locator('.dashboard-grid')).toBeVisible({ timeout: 15000 });
await page.getByText(/voir tous les devoirs/i).click();
await expect(
page.getByRole('heading', { name: /devoirs des enfants/i })
).toBeVisible({ timeout: 15000 });
});
});
});

View File

@@ -1,8 +1,12 @@
<script lang="ts"> <script lang="ts">
import type { DemoData } from '$types'; import type { DemoData } from '$types';
import type { ScheduleSlot } from '$lib/features/schedule/api/schedule'; import type { ScheduleSlot } from '$lib/features/schedule/api/schedule';
import type { StudentHomework, StudentHomeworkDetail } from '$lib/features/homework/api/studentHomework';
import { fetchChildDaySchedule } from '$lib/features/schedule/api/parentSchedule'; import { fetchChildDaySchedule } from '$lib/features/schedule/api/parentSchedule';
import type { ChildHomework } from '$lib/features/homework/api/parentHomework';
import { fetchChildrenHomework, fetchParentHomeworkDetail, getParentAttachmentUrl } from '$lib/features/homework/api/parentHomework';
import { recordSync } from '$lib/features/schedule/stores/scheduleCache.svelte'; import { recordSync } from '$lib/features/schedule/stores/scheduleCache.svelte';
import HomeworkDetail from '$lib/components/organisms/StudentHomework/HomeworkDetail.svelte';
import SerenityScorePreview from '$lib/components/molecules/SerenityScore/SerenityScorePreview.svelte'; import SerenityScorePreview from '$lib/components/molecules/SerenityScore/SerenityScorePreview.svelte';
import SerenityScoreExplainer from '$lib/components/molecules/SerenityScore/SerenityScoreExplainer.svelte'; import SerenityScoreExplainer from '$lib/components/molecules/SerenityScore/SerenityScoreExplainer.svelte';
import DashboardSection from '$lib/components/molecules/DashboardSection.svelte'; import DashboardSection from '$lib/components/molecules/DashboardSection.svelte';
@@ -69,13 +73,73 @@
} }
} }
// Load schedule when selectedChildId changes // Homework widget state
let childrenHomework = $state<ChildHomework[]>([]);
let homeworkLoading = $state(false);
interface HomeworkWithChild extends StudentHomework {
childFirstName: string;
}
let parentHomeworks = $derived<HomeworkWithChild[]>(
childrenHomework.flatMap((c) =>
c.homework.map((hw) => ({ ...hw, childFirstName: c.firstName }))
).sort((a, b) => a.dueDate.localeCompare(b.dueDate)).slice(0, 5)
);
async function loadHomeworks() {
homeworkLoading = true;
try {
childrenHomework = await fetchChildrenHomework();
} 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 fetchParentHomeworkDetail(homeworkId);
} catch {
window.location.href = '/dashboard/parent-homework';
}
}
function closeHomeworkDetail() {
selectedHomeworkDetail = null;
}
function handleOverlayClick(e: MouseEvent) {
if (e.target === e.currentTarget) closeHomeworkDetail();
}
function handleModalKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') closeHomeworkDetail();
}
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(() => { $effect(() => {
if (isParent && selectedChildId) { if (isParent && selectedChildId) {
loadChildSchedule(selectedChildId); loadChildSchedule(selectedChildId);
} }
}); });
$effect(() => {
if (isParent) {
loadHomeworks();
}
});
let showExplainer = $state(false); let showExplainer = $state(false);
const isDemo = $derived(!hasRealData); const isDemo = $derived(!hasRealData);
@@ -181,10 +245,34 @@
<!-- Devoirs Section --> <!-- Devoirs Section -->
<DashboardSection <DashboardSection
title="Devoirs à venir" title="Devoirs à venir"
isPlaceholder={!hasRealData} isPlaceholder={!isParent && !hasRealData}
placeholderMessage="Les devoirs seront affichés ici une fois assignés" 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 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>
<span class="homework-child">{homework.childFirstName}</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/parent-homework" class="view-all-link">
Voir tous les devoirs →
</a>
{/if}
{:else if hasRealData}
{#if isLoading} {#if isLoading}
<SkeletonList items={3} message="Chargement des devoirs..." /> <SkeletonList items={3} message="Chargement des devoirs..." />
{:else} {:else}
@@ -208,6 +296,16 @@
</div> </div>
</div> </div>
{#if selectedHomeworkDetail}
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div class="homework-modal-overlay" onclick={handleOverlayClick} onkeydown={handleModalKeydown} role="presentation">
<div class="homework-modal" role="dialog" aria-modal="true" aria-label="Détail du devoir">
<button class="homework-modal-close" onclick={closeHomeworkDetail} aria-label="Fermer">&times;</button>
<HomeworkDetail detail={selectedHomeworkDetail} onBack={closeHomeworkDetail} getAttachmentUrl={getParentAttachmentUrl} />
</div>
</div>
{/if}
{#if showExplainer} {#if showExplainer}
<SerenityScoreExplainer <SerenityScoreExplainer
score={demoData.serenityScore} score={demoData.serenityScore}
@@ -416,4 +514,97 @@
background: #fee2e2; background: #fee2e2;
color: #991b1b; color: #991b1b;
} }
button.homework-item {
width: 100%;
text-align: left;
cursor: pointer;
border: none;
background: #f9fafb;
}
button.homework-item:hover {
background: #f3f4f6;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.homework-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.125rem;
}
.homework-child {
font-size: 0.75rem;
font-weight: 500;
color: #6b7280;
background: #f3f4f6;
padding: 0.0625rem 0.375rem;
border-radius: 0.25rem;
}
.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;
}
/* Homework detail modal */
.homework-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
padding: 1rem;
}
.homework-modal {
position: relative;
background: white;
border-radius: 0.75rem;
padding: 1.5rem;
max-width: 40rem;
width: 100%;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
.homework-modal-close {
position: absolute;
top: 0.75rem;
right: 0.75rem;
background: none;
border: none;
font-size: 1.5rem;
color: #6b7280;
cursor: pointer;
line-height: 1;
padding: 0.25rem;
}
.homework-modal-close:hover {
color: #1f2937;
}
</style> </style>

View File

@@ -0,0 +1,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

@@ -1,14 +1,16 @@
<script lang="ts"> <script lang="ts">
import type { StudentHomeworkDetail } from '$lib/features/homework/api/studentHomework'; import type { StudentHomeworkDetail } from '$lib/features/homework/api/studentHomework';
import { getAttachmentUrl } from '$lib/features/homework/api/studentHomework'; import { getAttachmentUrl as defaultGetAttachmentUrl } from '$lib/features/homework/api/studentHomework';
import { authenticatedFetch } from '$lib/auth'; import { authenticatedFetch } from '$lib/auth';
let { let {
detail, detail,
onBack onBack,
getAttachmentUrl = defaultGetAttachmentUrl
}: { }: {
detail: StudentHomeworkDetail; detail: StudentHomeworkDetail;
onBack: () => void; onBack: () => void;
getAttachmentUrl?: (homeworkId: string, attachmentId: string) => string;
} = $props(); } = $props();
function formatDueDate(dateStr: string): string { function formatDueDate(dateStr: string): string {

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,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'; import { browser } from '$app/environment';
export { isOffline } from '$lib/utils/network';
const LAST_SYNC_KEY = 'classeo:schedule:lastSync'; const LAST_SYNC_KEY = 'classeo:schedule:lastSync';
let lastSyncValue = $state<string | null>( let lastSyncValue = $state<string | null>(
browser ? localStorage.getItem(LAST_SYNC_KEY) : 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. * 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}
{#if isParent} {#if isParent}
<a href="/dashboard/parent-schedule" class="nav-link" class:active={pathname === '/dashboard/parent-schedule'}>EDT enfants</a> <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} {/if}
<button class="nav-button" onclick={goSettings}>Paramètres</button> <button class="nav-button" onclick={goSettings}>Paramètres</button>
<button class="logout-button" onclick={handleLogout} disabled={isLoggingOut}> <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'}> <a href="/dashboard/parent-schedule" class="mobile-nav-link" class:active={pathname === '/dashboard/parent-schedule'}>
EDT enfants EDT enfants
</a> </a>
<a href="/dashboard/parent-homework" class="mobile-nav-link" class:active={pathname === '/dashboard/parent-homework'}>
Devoirs
</a>
{/if} {/if}
<button class="mobile-nav-link" onclick={goSettings}>Paramètres</button> <button class="mobile-nav-link" onclick={goSettings}>Paramètres</button>
</div> </div>

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>