diff --git a/backend/src/Administration/Application/Service/ValidDueDateSuggester.php b/backend/src/Administration/Application/Service/ValidDueDateSuggester.php new file mode 100644 index 0000000..598740a --- /dev/null +++ b/backend/src/Administration/Application/Service/ValidDueDateSuggester.php @@ -0,0 +1,68 @@ +estActif()) { + return []; + } + + $suggestions = []; + $checkDate = $requestedDate; + + for ($i = 0; $i < self::MAX_SEARCH_DAYS && count($suggestions) < $maxSuggestions; ++$i) { + $checkDate = $checkDate->modify('+1 day'); + + // Vérifier via le calendrier scolaire (weekends + fériés + vacances) + if ($calendar !== null) { + if (!$calendar->estJourOuvre($checkDate)) { + continue; + } + } elseif ((int) $checkDate->format('N') >= 6) { + // Fallback : sauter les weekends si pas de calendrier + continue; + } + + $result = $this->validator->valider($rules, $checkDate, $creationDate); + + if ($result->estValide()) { + $suggestions[] = $checkDate; + } + } + + return $suggestions; + } +} diff --git a/backend/src/Scolarite/Application/Command/CreateHomework/CreateHomeworkHandler.php b/backend/src/Scolarite/Application/Command/CreateHomework/CreateHomeworkHandler.php index b752b20..ae9d29c 100644 --- a/backend/src/Scolarite/Application/Command/CreateHomework/CreateHomeworkHandler.php +++ b/backend/src/Scolarite/Application/Command/CreateHomework/CreateHomeworkHandler.php @@ -52,7 +52,11 @@ final readonly class CreateHomeworkHandler $rulesResult = $this->rulesChecker->verifier($tenantId, $dueDate, $now); if ($rulesResult->estBloquant()) { - throw new ReglesDevoirsNonRespecteesException($rulesResult->toArray()); + throw new ReglesDevoirsNonRespecteesException( + $rulesResult->toArray(), + bloquant: true, + suggestedDates: $rulesResult->suggestedDates, + ); } if ($rulesResult->estAvertissement() && !$command->acknowledgeWarning) { diff --git a/backend/src/Scolarite/Application/Port/HomeworkRulesCheckResult.php b/backend/src/Scolarite/Application/Port/HomeworkRulesCheckResult.php index 2f64aae..5bfaf5e 100644 --- a/backend/src/Scolarite/Application/Port/HomeworkRulesCheckResult.php +++ b/backend/src/Scolarite/Application/Port/HomeworkRulesCheckResult.php @@ -17,10 +17,12 @@ final readonly class HomeworkRulesCheckResult { /** * @param RuleWarning[] $warnings + * @param string[] $suggestedDates Dates conformes alternatives (format Y-m-d) */ public function __construct( public array $warnings, public bool $bloquant, + public array $suggestedDates = [], ) { } diff --git a/backend/src/Scolarite/Domain/Exception/ReglesDevoirsNonRespecteesException.php b/backend/src/Scolarite/Domain/Exception/ReglesDevoirsNonRespecteesException.php index 837e1a4..3f7418d 100644 --- a/backend/src/Scolarite/Domain/Exception/ReglesDevoirsNonRespecteesException.php +++ b/backend/src/Scolarite/Domain/Exception/ReglesDevoirsNonRespecteesException.php @@ -4,8 +4,12 @@ declare(strict_types=1); namespace App\Scolarite\Domain\Exception; +use function array_column; + use DomainException; +use function implode; + /** * Levée quand un devoir enfreint les règles configurées * et que l'enseignant n'a pas encore confirmé. @@ -14,10 +18,19 @@ final class ReglesDevoirsNonRespecteesException extends DomainException { /** * @param array}> $warnings + * @param string[] $suggestedDates Dates conformes alternatives (format Y-m-d) */ public function __construct( public readonly array $warnings, + public readonly bool $bloquant = false, + public readonly array $suggestedDates = [], ) { - parent::__construct('Le devoir ne respecte pas les règles configurées.'); + $raisons = implode(' ', array_column($warnings, 'message')); + + parent::__construct( + $bloquant + ? 'Impossible de créer ce devoir : ' . $raisons + : 'Le devoir ne respecte pas les règles configurées.', + ); } } diff --git a/backend/src/Scolarite/Infrastructure/Api/Processor/CreateHomeworkProcessor.php b/backend/src/Scolarite/Infrastructure/Api/Processor/CreateHomeworkProcessor.php index bd59c83..9886518 100644 --- a/backend/src/Scolarite/Infrastructure/Api/Processor/CreateHomeworkProcessor.php +++ b/backend/src/Scolarite/Infrastructure/Api/Processor/CreateHomeworkProcessor.php @@ -72,6 +72,16 @@ final readonly class CreateHomeworkProcessor implements ProcessorInterface return HomeworkResource::fromDomain($homework); } catch (ReglesDevoirsNonRespecteesException $e) { + if ($e->bloquant) { + return new JsonResponse([ + 'type' => 'homework_rules_blocked', + 'message' => $e->getMessage(), + 'warnings' => $e->warnings, + 'suggestedDates' => $e->suggestedDates, + 'exceptionRequestPath' => '/dashboard/teacher/homework/request-exception', + ], Response::HTTP_UNPROCESSABLE_ENTITY); + } + return new JsonResponse([ 'type' => 'homework_rules_warning', 'message' => $e->getMessage(), diff --git a/backend/src/Scolarite/Infrastructure/Service/AdministrationHomeworkRulesChecker.php b/backend/src/Scolarite/Infrastructure/Service/AdministrationHomeworkRulesChecker.php index 3da5809..b59aad1 100644 --- a/backend/src/Scolarite/Infrastructure/Service/AdministrationHomeworkRulesChecker.php +++ b/backend/src/Scolarite/Infrastructure/Service/AdministrationHomeworkRulesChecker.php @@ -7,9 +7,11 @@ namespace App\Scolarite\Infrastructure\Service; use App\Administration\Application\Service\HomeworkRulesValidationResult; use App\Administration\Application\Service\HomeworkRulesValidator; use App\Administration\Application\Service\RuleViolation; +use App\Administration\Application\Service\ValidDueDateSuggester; use App\Administration\Domain\Model\HomeworkRules\HomeworkRule; use App\Administration\Domain\Model\HomeworkRules\HomeworkRules; use App\Administration\Domain\Repository\HomeworkRulesRepository; +use App\Scolarite\Application\Port\CurrentCalendarProvider; use App\Scolarite\Application\Port\HomeworkRulesChecker; use App\Scolarite\Application\Port\HomeworkRulesCheckResult; use App\Scolarite\Application\Port\RuleWarning; @@ -28,6 +30,8 @@ final readonly class AdministrationHomeworkRulesChecker implements HomeworkRules public function __construct( private HomeworkRulesRepository $rulesRepository, private HomeworkRulesValidator $validator, + private ValidDueDateSuggester $suggester, + private CurrentCalendarProvider $calendarProvider, ) { } @@ -45,11 +49,25 @@ final readonly class AdministrationHomeworkRulesChecker implements HomeworkRules $result = $this->validator->valider($rules, $dueDate, $creationDate); - return $this->toCheckResult($result, $rules); + return $this->toCheckResult($result, $rules, $dueDate, $creationDate); } - private function toCheckResult(HomeworkRulesValidationResult $result, HomeworkRules $rules): HomeworkRulesCheckResult - { + private function toCheckResult( + HomeworkRulesValidationResult $result, + HomeworkRules $rules, + DateTimeImmutable $dueDate, + DateTimeImmutable $creationDate, + ): HomeworkRulesCheckResult { + $suggestedDates = []; + + if ($result->estBloquant()) { + $calendar = $this->calendarProvider->forCurrentYear($rules->tenantId); + $suggestedDates = array_map( + static fn (DateTimeImmutable $d): string => $d->format('Y-m-d'), + $this->suggester->suggerer($dueDate, $rules, $creationDate, calendar: $calendar), + ); + } + return new HomeworkRulesCheckResult( warnings: array_map( fn (RuleViolation $v): RuleWarning => new RuleWarning( @@ -60,6 +78,7 @@ final readonly class AdministrationHomeworkRulesChecker implements HomeworkRules $result->violations, ), bloquant: $result->estBloquant(), + suggestedDates: $suggestedDates, ); } diff --git a/backend/tests/Functional/Scolarite/Api/HomeworkEndpointsTest.php b/backend/tests/Functional/Scolarite/Api/HomeworkEndpointsTest.php index 95eb433..aa23c37 100644 --- a/backend/tests/Functional/Scolarite/Api/HomeworkEndpointsTest.php +++ b/backend/tests/Functional/Scolarite/Api/HomeworkEndpointsTest.php @@ -623,6 +623,75 @@ final class HomeworkEndpointsTest extends ApiTestCase ]); } + // ========================================================================= + // 5.5-FUNC-001 (P0) - POST /homework with hard rules violated → 422 with blocked response + // ========================================================================= + + #[Test] + public function createHomeworkReturns422WhenHardRulesViolated(): void + { + $this->persistHardRulesWithMinimumDelay(7); + $this->seedTeacherAssignment(); + + $client = $this->createAuthenticatedClient(self::OWNER_TEACHER_ID, ['ROLE_PROF']); + + $tomorrow = (new DateTimeImmutable('+1 weekday'))->format('Y-m-d'); + + $client->request('POST', 'http://ecole-alpha.classeo.local/api/homework', [ + 'headers' => [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + ], + 'json' => [ + 'classId' => self::CLASS_ID, + 'subjectId' => self::SUBJECT_ID, + 'title' => 'Devoir test 422', + 'dueDate' => $tomorrow, + ], + ]); + + self::assertResponseStatusCodeSame(422); + $json = $client->getResponse()->toArray(false); + self::assertSame('homework_rules_blocked', $json['type']); + self::assertNotEmpty($json['warnings']); + self::assertSame('minimum_delay', $json['warnings'][0]['ruleType']); + self::assertArrayHasKey('suggestedDates', $json); + self::assertArrayHasKey('exceptionRequestPath', $json); + } + + // ========================================================================= + // 5.5-FUNC-002 (P0) - POST /homework with hard rules violated + acknowledgeWarning → still 422 + // ========================================================================= + + #[Test] + public function createHomeworkReturns422EvenWhenHardRulesAcknowledged(): void + { + $this->persistHardRulesWithMinimumDelay(7); + $this->seedTeacherAssignment(); + + $client = $this->createAuthenticatedClient(self::OWNER_TEACHER_ID, ['ROLE_PROF']); + + $tomorrow = (new DateTimeImmutable('+1 weekday'))->format('Y-m-d'); + + $client->request('POST', 'http://ecole-alpha.classeo.local/api/homework', [ + 'headers' => [ + 'Accept' => 'application/json', + 'Content-Type' => 'application/json', + ], + 'json' => [ + 'classId' => self::CLASS_ID, + 'subjectId' => self::SUBJECT_ID, + 'title' => 'Devoir bypass hard', + 'dueDate' => $tomorrow, + 'acknowledgeWarning' => true, + ], + ]); + + self::assertResponseStatusCodeSame(422); + $json = $client->getResponse()->toArray(false); + self::assertSame('homework_rules_blocked', $json['type']); + } + // ========================================================================= // Helpers // ========================================================================= @@ -657,6 +726,19 @@ final class HomeworkEndpointsTest extends ApiTestCase ); } + private function persistHardRulesWithMinimumDelay(int $days): void + { + /** @var Connection $connection */ + $connection = static::getContainer()->get(Connection::class); + $rulesJson = json_encode([['type' => 'minimum_delay', 'params' => ['days' => $days]]], JSON_THROW_ON_ERROR); + $connection->executeStatement( + "INSERT INTO homework_rules (id, tenant_id, rules, enforcement_mode, enabled, created_at, updated_at) + VALUES (gen_random_uuid(), :tid, :rules::jsonb, 'hard', true, NOW(), NOW()) + ON CONFLICT (tenant_id) DO UPDATE SET rules = :rules::jsonb, enforcement_mode = 'hard', enabled = true, updated_at = NOW()", + ['tid' => self::TENANT_ID, 'rules' => $rulesJson], + ); + } + private function seedTeacherAssignment(): void { /** @var Connection $connection */ diff --git a/backend/tests/Unit/Administration/Application/Service/ValidDueDateSuggesterTest.php b/backend/tests/Unit/Administration/Application/Service/ValidDueDateSuggesterTest.php new file mode 100644 index 0000000..a664c65 --- /dev/null +++ b/backend/tests/Unit/Administration/Application/Service/ValidDueDateSuggesterTest.php @@ -0,0 +1,204 @@ +suggester = new ValidDueDateSuggester(new HomeworkRulesValidator()); + } + + #[Test] + public function suggestsNextValidDatesForMinimumDelay(): void + { + $rules = $this->creerRulesAvecDelai(3); + $creationDate = new DateTimeImmutable('2026-03-18 10:00'); // mercredi + + // Requested date is too soon (jeudi 19 mars, only 1 day) + $suggestions = $this->suggester->suggerer( + new DateTimeImmutable('2026-03-19'), + $rules, + $creationDate, + ); + + self::assertNotEmpty($suggestions); + self::assertLessThanOrEqual(3, count($suggestions)); + + // Each suggestion must be valid against the rules + $validator = new HomeworkRulesValidator(); + foreach ($suggestions as $date) { + self::assertInstanceOf(DateTimeImmutable::class, $date); + $result = $validator->valider($rules, $date, $creationDate); + self::assertTrue($result->estValide(), 'Suggested date ' . $date->format('Y-m-d') . ' should be valid'); + } + } + + #[Test] + public function suggestsUpToMaxSuggestions(): void + { + $rules = $this->creerRulesAvecDelai(3); + $creationDate = new DateTimeImmutable('2026-03-18 10:00'); + + $suggestions = $this->suggester->suggerer( + new DateTimeImmutable('2026-03-19'), + $rules, + $creationDate, + maxSuggestions: 5, + ); + + self::assertCount(5, $suggestions); + } + + #[Test] + public function skipsWeekends(): void + { + $rules = $this->creerRulesAvecDelai(3); + // Jeudi 19 mars, creation date is mercredi 18 mars + $creationDate = new DateTimeImmutable('2026-03-18 10:00'); + + $suggestions = $this->suggester->suggerer( + new DateTimeImmutable('2026-03-19'), + $rules, + $creationDate, + ); + + foreach ($suggestions as $date) { + $dayOfWeek = (int) $date->format('N'); + self::assertLessThanOrEqual(5, $dayOfWeek, 'Suggested date ' . $date->format('Y-m-d') . ' should not be a weekend'); + } + } + + #[Test] + public function suggestsDatesInChronologicalOrder(): void + { + $rules = $this->creerRulesAvecDelai(3); + $creationDate = new DateTimeImmutable('2026-03-18 10:00'); + + $suggestions = $this->suggester->suggerer( + new DateTimeImmutable('2026-03-19'), + $rules, + $creationDate, + ); + + for ($i = 1; $i < count($suggestions); ++$i) { + self::assertGreaterThan( + $suggestions[$i - 1]->format('Y-m-d'), + $suggestions[$i]->format('Y-m-d'), + 'Suggestions should be in chronological order', + ); + } + } + + #[Test] + public function returnsEmptyWhenRulesDisabled(): void + { + $rules = $this->creerRulesAvecDelai(3, EnforcementMode::DISABLED, false); + $creationDate = new DateTimeImmutable('2026-03-18 10:00'); + + $suggestions = $this->suggester->suggerer( + new DateTimeImmutable('2026-03-19'), + $rules, + $creationDate, + ); + + self::assertEmpty($suggestions); + } + + #[Test] + public function searchIsBoundedAndDoesNotRunForever(): void + { + $rules = $this->creerRulesAvecDelai(30); + $creationDate = new DateTimeImmutable('2026-03-18 10:00'); + + $suggestions = $this->suggester->suggerer( + new DateTimeImmutable('2026-03-19'), + $rules, + $creationDate, + maxSuggestions: 100, + ); + + // La recherche est bornée à 60 jours. Avec un délai de 30 jours, + // seules les dates après le 17 avril sont valides (~21 jours ouvrés). + self::assertNotEmpty($suggestions); + self::assertLessThan(100, count($suggestions)); + } + + #[Test] + public function suggestsValidDatesForMondayRule(): void + { + $rules = $this->creerRulesAvecLundi(); + // Samedi 21 mars, trying to create homework for lundi 23 mars (after friday cutoff) + $creationDate = new DateTimeImmutable('2026-03-21 10:00'); + + $suggestions = $this->suggester->suggerer( + new DateTimeImmutable('2026-03-23'), // lundi + $rules, + $creationDate, + ); + + self::assertNotEmpty($suggestions); + + $validator = new HomeworkRulesValidator(); + foreach ($suggestions as $date) { + $result = $validator->valider($rules, $date, $creationDate); + self::assertTrue($result->estValide(), 'Suggested date ' . $date->format('Y-m-d') . ' should be valid'); + } + } + + private function creerRulesAvecDelai( + int $days, + EnforcementMode $mode = EnforcementMode::HARD, + bool $enabled = true, + ): HomeworkRules { + $rules = HomeworkRules::creer( + tenantId: TenantId::fromString('550e8400-e29b-41d4-a716-446655440001'), + now: new DateTimeImmutable('2026-03-01'), + ); + + $rules->mettreAJour( + rules: [new HomeworkRule(RuleType::MINIMUM_DELAY, ['days' => $days])], + enforcementMode: $mode, + enabled: $enabled, + now: new DateTimeImmutable('2026-03-01'), + ); + + return $rules; + } + + private function creerRulesAvecLundi( + EnforcementMode $mode = EnforcementMode::HARD, + ): HomeworkRules { + $rules = HomeworkRules::creer( + tenantId: TenantId::fromString('550e8400-e29b-41d4-a716-446655440001'), + now: new DateTimeImmutable('2026-03-01'), + ); + + $rules->mettreAJour( + rules: [new HomeworkRule(RuleType::NO_MONDAY_AFTER, ['day' => 'friday', 'time' => '12:00'])], + enforcementMode: $mode, + enabled: true, + now: new DateTimeImmutable('2026-03-01'), + ); + + return $rules; + } +} diff --git a/backend/tests/Unit/Scolarite/Application/Command/CreateHomework/CreateHomeworkHandlerTest.php b/backend/tests/Unit/Scolarite/Application/Command/CreateHomework/CreateHomeworkHandlerTest.php index a28ec7d..98b6e63 100644 --- a/backend/tests/Unit/Scolarite/Application/Command/CreateHomework/CreateHomeworkHandlerTest.php +++ b/backend/tests/Unit/Scolarite/Application/Command/CreateHomework/CreateHomeworkHandlerTest.php @@ -158,6 +158,33 @@ final class CreateHomeworkHandlerTest extends TestCase $handler($this->createCommand(acknowledgeWarning: true)); } + #[Test] + public function hardBlockingExceptionCarriesSuggestedDatesAndBloquantFlag(): void + { + $warning = new RuleWarning( + ruleType: 'minimum_delay', + message: 'Le devoir doit être créé au moins 7 jours avant.', + params: ['days' => 7], + ); + $suggestedDates = ['2026-03-25', '2026-03-26', '2026-03-27']; + $rulesResult = new HomeworkRulesCheckResult( + warnings: [$warning], + bloquant: true, + suggestedDates: $suggestedDates, + ); + $handler = $this->createHandler(affecte: true, rulesResult: $rulesResult); + + try { + $handler($this->createCommand(acknowledgeWarning: false)); + self::fail('Expected ReglesDevoirsNonRespecteesException'); + } catch (ReglesDevoirsNonRespecteesException $e) { + self::assertTrue($e->bloquant); + self::assertSame($suggestedDates, $e->suggestedDates); + self::assertCount(1, $e->warnings); + self::assertSame('minimum_delay', $e->warnings[0]['ruleType']); + } + } + #[Test] public function itCreatesHomeworkWhenSoftRulesViolatedButAcknowledged(): void { diff --git a/backend/tests/Unit/Scolarite/Application/Port/HomeworkRulesCheckResultTest.php b/backend/tests/Unit/Scolarite/Application/Port/HomeworkRulesCheckResultTest.php new file mode 100644 index 0000000..582d913 --- /dev/null +++ b/backend/tests/Unit/Scolarite/Application/Port/HomeworkRulesCheckResultTest.php @@ -0,0 +1,140 @@ +estValide()); + self::assertFalse($result->estAvertissement()); + self::assertFalse($result->estBloquant()); + self::assertEmpty($result->warnings); + self::assertEmpty($result->suggestedDates); + self::assertFalse($result->bloquant); + } + + #[Test] + public function estBloquantReturnsTrueWhenBloquantWithWarnings(): void + { + $result = new HomeworkRulesCheckResult( + warnings: [$this->uneWarning()], + bloquant: true, + ); + + self::assertTrue($result->estBloquant()); + self::assertFalse($result->estAvertissement()); + self::assertFalse($result->estValide()); + } + + #[Test] + public function estAvertissementReturnsTrueWhenNonBloquantWithWarnings(): void + { + $result = new HomeworkRulesCheckResult( + warnings: [$this->uneWarning()], + bloquant: false, + ); + + self::assertTrue($result->estAvertissement()); + self::assertFalse($result->estBloquant()); + self::assertFalse($result->estValide()); + } + + #[Test] + public function estValideReturnsFalseWhenBloquantWithoutWarnings(): void + { + $result = new HomeworkRulesCheckResult( + warnings: [], + bloquant: true, + ); + + self::assertTrue($result->estValide()); + self::assertFalse($result->estBloquant()); + self::assertFalse($result->estAvertissement()); + } + + #[Test] + public function suggestedDatesAreStoredAndAccessible(): void + { + $dates = ['2026-03-25', '2026-03-26', '2026-03-27']; + + $result = new HomeworkRulesCheckResult( + warnings: [$this->uneWarning()], + bloquant: true, + suggestedDates: $dates, + ); + + self::assertSame($dates, $result->suggestedDates); + } + + #[Test] + public function messagesReturnsWarningMessages(): void + { + $result = new HomeworkRulesCheckResult( + warnings: [ + new RuleWarning('minimum_delay', 'Au moins 3 jours.', ['days' => 3]), + new RuleWarning('no_monday_after', 'Pas après vendredi 12h.', []), + ], + bloquant: false, + ); + + self::assertSame([ + 'Au moins 3 jours.', + 'Pas après vendredi 12h.', + ], $result->messages()); + } + + #[Test] + public function ruleTypesReturnsWarningRuleTypes(): void + { + $result = new HomeworkRulesCheckResult( + warnings: [ + new RuleWarning('minimum_delay', 'msg1', []), + new RuleWarning('no_monday_after', 'msg2', []), + ], + bloquant: false, + ); + + self::assertSame(['minimum_delay', 'no_monday_after'], $result->ruleTypes()); + } + + #[Test] + public function toArrayReturnsStructuredWarnings(): void + { + $result = new HomeworkRulesCheckResult( + warnings: [ + new RuleWarning('minimum_delay', 'Au moins 3 jours.', ['days' => 3]), + ], + bloquant: true, + ); + + $expected = [ + [ + 'ruleType' => 'minimum_delay', + 'message' => 'Au moins 3 jours.', + 'params' => ['days' => 3], + ], + ]; + + self::assertSame($expected, $result->toArray()); + } + + private function uneWarning(): RuleWarning + { + return new RuleWarning( + ruleType: 'minimum_delay', + message: 'Le devoir doit être créé au moins 3 jours avant.', + params: ['days' => 3], + ); + } +} diff --git a/backend/tests/Unit/Scolarite/Domain/Exception/ReglesDevoirsNonRespecteesExceptionTest.php b/backend/tests/Unit/Scolarite/Domain/Exception/ReglesDevoirsNonRespecteesExceptionTest.php new file mode 100644 index 0000000..ee8f3b0 --- /dev/null +++ b/backend/tests/Unit/Scolarite/Domain/Exception/ReglesDevoirsNonRespecteesExceptionTest.php @@ -0,0 +1,77 @@ + 'minimum_delay', 'message' => 'msg', 'params' => []]], + bloquant: true, + ); + + self::assertStringContainsString('Impossible de créer ce devoir', $exception->getMessage()); + self::assertStringContainsString('msg', $exception->getMessage()); + self::assertTrue($exception->bloquant); + } + + #[Test] + public function warningExceptionHasWarningMessage(): void + { + $exception = new ReglesDevoirsNonRespecteesException( + warnings: [['ruleType' => 'minimum_delay', 'message' => 'msg', 'params' => []]], + ); + + self::assertStringContainsString('ne respecte pas', $exception->getMessage()); + self::assertFalse($exception->bloquant); + } + + #[Test] + public function suggestedDatesAreAccessible(): void + { + $dates = ['2026-03-25', '2026-03-26']; + + $exception = new ReglesDevoirsNonRespecteesException( + warnings: [['ruleType' => 'minimum_delay', 'message' => 'msg', 'params' => []]], + bloquant: true, + suggestedDates: $dates, + ); + + self::assertSame($dates, $exception->suggestedDates); + } + + #[Test] + public function suggestedDatesDefaultToEmpty(): void + { + $exception = new ReglesDevoirsNonRespecteesException( + warnings: [['ruleType' => 'minimum_delay', 'message' => 'msg', 'params' => []]], + bloquant: true, + ); + + self::assertEmpty($exception->suggestedDates); + } + + #[Test] + public function warningsAreAccessible(): void + { + $warnings = [ + ['ruleType' => 'minimum_delay', 'message' => 'Au moins 3 jours.', 'params' => ['days' => 3]], + ['ruleType' => 'no_monday_after', 'message' => 'Pas après vendredi.', 'params' => []], + ]; + + $exception = new ReglesDevoirsNonRespecteesException( + warnings: $warnings, + bloquant: true, + ); + + self::assertSame($warnings, $exception->warnings); + } +} diff --git a/backend/tests/Unit/Scolarite/Infrastructure/Service/AdministrationHomeworkRulesCheckerTest.php b/backend/tests/Unit/Scolarite/Infrastructure/Service/AdministrationHomeworkRulesCheckerTest.php new file mode 100644 index 0000000..6afe1a6 --- /dev/null +++ b/backend/tests/Unit/Scolarite/Infrastructure/Service/AdministrationHomeworkRulesCheckerTest.php @@ -0,0 +1,189 @@ +validator = new HomeworkRulesValidator(); + $this->suggester = new ValidDueDateSuggester($this->validator); + } + + #[Test] + public function returnsOkWhenNoRulesConfigured(): void + { + $checker = $this->createChecker(rules: null); + $tenantId = TenantId::fromString(self::TENANT_ID); + + $result = $checker->verifier( + $tenantId, + new DateTimeImmutable('2026-03-25'), + new DateTimeImmutable('2026-03-18 10:00'), + ); + + self::assertTrue($result->estValide()); + self::assertFalse($result->bloquant); + self::assertEmpty($result->suggestedDates); + } + + #[Test] + public function returnsOkWhenRulesRespected(): void + { + $rules = $this->creerRulesAvecDelai(3, EnforcementMode::HARD); + $checker = $this->createChecker(rules: $rules); + $tenantId = TenantId::fromString(self::TENANT_ID); + + $result = $checker->verifier( + $tenantId, + new DateTimeImmutable('2026-03-25'), // 7 days ahead, > 3 day min + new DateTimeImmutable('2026-03-18 10:00'), + ); + + self::assertTrue($result->estValide()); + self::assertEmpty($result->suggestedDates); + } + + #[Test] + public function returnsBlockingResultWithSuggestedDatesInHardMode(): void + { + $rules = $this->creerRulesAvecDelai(7, EnforcementMode::HARD); + $checker = $this->createChecker(rules: $rules); + $tenantId = TenantId::fromString(self::TENANT_ID); + + $result = $checker->verifier( + $tenantId, + new DateTimeImmutable('2026-03-19'), // only 1 day ahead, < 7 day min + new DateTimeImmutable('2026-03-18 10:00'), + ); + + self::assertTrue($result->estBloquant()); + self::assertTrue($result->bloquant); + self::assertNotEmpty($result->suggestedDates); + self::assertCount(3, $result->suggestedDates); + + // Suggested dates are formatted as Y-m-d strings + foreach ($result->suggestedDates as $date) { + self::assertMatchesRegularExpression('/^\d{4}-\d{2}-\d{2}$/', $date); + } + } + + #[Test] + public function returnsWarningWithoutSuggestedDatesInSoftMode(): void + { + $rules = $this->creerRulesAvecDelai(7, EnforcementMode::SOFT); + $checker = $this->createChecker(rules: $rules); + $tenantId = TenantId::fromString(self::TENANT_ID); + + $result = $checker->verifier( + $tenantId, + new DateTimeImmutable('2026-03-19'), // only 1 day ahead, < 7 day min + new DateTimeImmutable('2026-03-18 10:00'), + ); + + self::assertTrue($result->estAvertissement()); + self::assertFalse($result->bloquant); + self::assertEmpty($result->suggestedDates); + } + + #[Test] + public function warningsContainRuleParams(): void + { + $rules = $this->creerRulesAvecDelai(5, EnforcementMode::SOFT); + $checker = $this->createChecker(rules: $rules); + $tenantId = TenantId::fromString(self::TENANT_ID); + + $result = $checker->verifier( + $tenantId, + new DateTimeImmutable('2026-03-19'), + new DateTimeImmutable('2026-03-18 10:00'), + ); + + self::assertCount(1, $result->warnings); + self::assertSame('minimum_delay', $result->warnings[0]->ruleType); + self::assertSame(['days' => 5], $result->warnings[0]->params); + } + + private function createChecker(?HomeworkRules $rules): AdministrationHomeworkRulesChecker + { + $repository = new class($rules) implements HomeworkRulesRepository { + public function __construct(private readonly ?HomeworkRules $rules) + { + } + + public function save(HomeworkRules $homeworkRules): void + { + } + + public function get(TenantId $tenantId): HomeworkRules + { + return $this->rules ?? throw new RuntimeException('Not found'); + } + + public function findByTenantId(TenantId $tenantId): ?HomeworkRules + { + return $this->rules; + } + }; + + $calendarProvider = new class implements CurrentCalendarProvider { + public function forCurrentYear(TenantId $tenantId): SchoolCalendar + { + return SchoolCalendar::reconstitute( + tenantId: $tenantId, + academicYearId: AcademicYearId::fromString('550e8400-e29b-41d4-a716-446655440002'), + zone: null, + entries: [], + ); + } + }; + + return new AdministrationHomeworkRulesChecker( + $repository, + $this->validator, + $this->suggester, + $calendarProvider, + ); + } + + private function creerRulesAvecDelai(int $days, EnforcementMode $mode): HomeworkRules + { + $rules = HomeworkRules::creer( + tenantId: TenantId::fromString(self::TENANT_ID), + now: new DateTimeImmutable('2026-03-01'), + ); + + $rules->mettreAJour( + rules: [new HomeworkRule(RuleType::MINIMUM_DELAY, ['days' => $days])], + enforcementMode: $mode, + enabled: true, + now: new DateTimeImmutable('2026-03-01'), + ); + + return $rules; + } +} diff --git a/frontend/e2e/homework-rules-hard.spec.ts b/frontend/e2e/homework-rules-hard.spec.ts new file mode 100644 index 0000000..890c9e7 --- /dev/null +++ b/frontend/e2e/homework-rules-hard.spec.ts @@ -0,0 +1,347 @@ +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 TEACHER_EMAIL = 'e2e-rules-hard-teacher@example.com'; +const TEACHER_PASSWORD = 'RulesHard123'; +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! }; +} + +/** + * Returns a weekday date string (YYYY-MM-DD), N days from now. + * Skips weekends. + */ +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 loginAsTeacher(page: import('@playwright/test').Page) { + await page.goto(`${ALPHA_URL}/login`); + await page.locator('#email').fill(TEACHER_EMAIL); + await page.locator('#password').fill(TEACHER_PASSWORD); + await Promise.all([ + page.waitForURL(/\/dashboard/, { timeout: 30000 }), + page.getByRole('button', { name: /se connecter/i }).click(), + ]); +} + +async function navigateToHomework(page: import('@playwright/test').Page) { + await page.goto(`${ALPHA_URL}/dashboard/teacher/homework`); + await expect(page.getByRole('heading', { name: /mes devoirs/i })).toBeVisible({ timeout: 15000 }); +} + +function seedTeacherAssignments() { + const { academicYearId } = resolveDeterministicIds(); + try { + runSql( + `INSERT INTO teacher_assignments (id, tenant_id, teacher_id, school_class_id, subject_id, academic_year_id, status, start_date, created_at, updated_at) ` + + `SELECT gen_random_uuid(), '${TENANT_ID}', u.id, c.id, s.id, '${academicYearId}', 'active', NOW(), NOW(), NOW() ` + + `FROM users u CROSS JOIN school_classes c CROSS JOIN subjects s ` + + `WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` + + `AND c.tenant_id = '${TENANT_ID}' ` + + `AND s.tenant_id = '${TENANT_ID}' ` + + `ON CONFLICT DO NOTHING`, + ); + } catch { + // Table may not exist + } +} + +/** + * Configure homework rules with minimum_delay of 7 days in hard mode + * so that a homework due in 1-2 days triggers a blocking error. + */ +function seedHardRules() { + const rulesJson = '[{\\"type\\":\\"minimum_delay\\",\\"params\\":{\\"days\\":7}}]'; + runSql( + `INSERT INTO homework_rules (id, tenant_id, rules, enforcement_mode, enabled, created_at, updated_at) ` + + `VALUES (gen_random_uuid(), '${TENANT_ID}', '${rulesJson}'::jsonb, 'hard', true, NOW(), NOW()) ` + + `ON CONFLICT (tenant_id) DO UPDATE SET rules = '${rulesJson}'::jsonb, enforcement_mode = 'hard', enabled = true, updated_at = NOW()`, + ); +} + +function clearRules() { + try { + runSql(`DELETE FROM homework_rules WHERE tenant_id = '${TENANT_ID}'`); + } catch { + // Table may not exist + } +} + +async function openCreateAndFillForm(page: import('@playwright/test').Page, title: string, daysFromNow: number) { + await page.getByRole('button', { name: /nouveau devoir/i }).click(); + await expect(page.getByRole('dialog')).toBeVisible({ timeout: 5000 }); + + const nearDate = getNextWeekday(daysFromNow); + await page.locator('#hw-class').selectOption({ index: 1 }); + await expect(page.locator('#hw-subject')).toBeEnabled({ timeout: 5000 }); + await page.locator('#hw-subject').selectOption({ index: 1 }); + await page.locator('#hw-title').fill(title); + await page.locator('#hw-due-date').fill(nearDate); + + await page.getByRole('button', { name: /créer le devoir/i }).click(); +} + +test.describe('Homework Rules - Hard Mode Blocking (Story 5.5)', () => { + test.beforeAll(async () => { + // 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(); + 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-RH-6A', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`, + ); + runSql( + `INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at) ` + + `VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-RH-Maths', 'E2ERHM', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`, + ); + } catch { + // May already exist + } + + seedTeacherAssignments(); + }); + + test.beforeEach(async () => { + try { + runSql( + `DELETE FROM homework WHERE tenant_id = '${TENANT_ID}' AND teacher_id IN (SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}')`, + ); + } catch { + // Table may not exist + } + + clearRules(); + clearCache(); + }); + + // ============================================================================ + // AC1: Blocking when hard mode is active + // ============================================================================ + test.describe('AC1: Hard mode blocks creation', () => { + test('shows blocking modal instead of warning when hard mode is active', async ({ page }) => { + seedHardRules(); + clearCache(); + + await loginAsTeacher(page); + await navigateToHomework(page); + await openCreateAndFillForm(page, 'Devoir hard block', 2); + + // Blocking modal appears (not warning) + const blockedDialog = page.getByRole('alertdialog'); + await expect(blockedDialog).toBeVisible({ timeout: 10000 }); + await expect(blockedDialog.getByText(/impossible de créer ce devoir/i)).toBeVisible(); + + // No "Continuer malgré tout" button + await expect(blockedDialog.getByRole('button', { name: /continuer malgré tout/i })).not.toBeVisible(); + }); + + test('cannot bypass hard mode with acknowledgment', async ({ page }) => { + seedHardRules(); + clearCache(); + + await loginAsTeacher(page); + await navigateToHomework(page); + await openCreateAndFillForm(page, 'Devoir bypass test', 2); + + const blockedDialog = page.getByRole('alertdialog'); + await expect(blockedDialog).toBeVisible({ timeout: 10000 }); + + // Only "Modifier la date" button, no bypass option + await expect(blockedDialog.getByRole('button', { name: /modifier la date/i })).toBeVisible(); + await expect(blockedDialog.getByRole('button', { name: /continuer/i })).not.toBeVisible(); + }); + }); + + // ============================================================================ + // AC2: Blocking message with reason and suggestions + // ============================================================================ + test.describe('AC2: Blocking message with suggestions', () => { + test('shows violation reason in blocking modal', async ({ page }) => { + seedHardRules(); + clearCache(); + + await loginAsTeacher(page); + await navigateToHomework(page); + await openCreateAndFillForm(page, 'Devoir raison blocage', 2); + + const blockedDialog = page.getByRole('alertdialog'); + await expect(blockedDialog).toBeVisible({ timeout: 10000 }); + + // Shows the reason + await expect(blockedDialog.getByText(/au moins/i)).toBeVisible(); + await expect(blockedDialog.getByText(/interdisent la création/i)).toBeVisible(); + }); + + test('shows suggested conforming dates', async ({ page }) => { + seedHardRules(); + clearCache(); + + await loginAsTeacher(page); + await navigateToHomework(page); + await openCreateAndFillForm(page, 'Devoir suggestions', 2); + + const blockedDialog = page.getByRole('alertdialog'); + await expect(blockedDialog).toBeVisible({ timeout: 10000 }); + + // Suggested dates section visible + await expect(blockedDialog.getByText(/dates conformes suggérées/i)).toBeVisible(); + + // At least one suggested date button + const suggestedGroup = blockedDialog.getByRole('group', { name: /dates conformes suggérées/i }); + await expect(suggestedGroup.getByRole('button').first()).toBeVisible(); + }); + + test('clicking suggested date reopens form with that date', async ({ page }) => { + seedHardRules(); + clearCache(); + + await loginAsTeacher(page); + await navigateToHomework(page); + await openCreateAndFillForm(page, 'Devoir select date', 2); + + const blockedDialog = page.getByRole('alertdialog'); + await expect(blockedDialog).toBeVisible({ timeout: 10000 }); + + // Click first suggested date + const firstSuggested = blockedDialog.getByRole('group', { name: /dates conformes suggérées/i }).getByRole('button').first(); + await firstSuggested.click(); + + // Blocked modal closes, create form reopens + await expect(blockedDialog).not.toBeVisible({ timeout: 3000 }); + const createDialog = page.getByRole('dialog'); + await expect(createDialog).toBeVisible(); + await expect(createDialog.locator('#hw-due-date')).toBeVisible(); + }); + }); + + // ============================================================================ + // AC3: Exception request information + // ============================================================================ + test.describe('AC3: Exception request information', () => { + test('shows exception contact information in blocking modal', async ({ page }) => { + seedHardRules(); + clearCache(); + + await loginAsTeacher(page); + await navigateToHomework(page); + await openCreateAndFillForm(page, 'Devoir exception link', 2); + + const blockedDialog = page.getByRole('alertdialog'); + await expect(blockedDialog).toBeVisible({ timeout: 10000 }); + + // Exception information visible + await expect(blockedDialog.getByText(/exception.*contactez/i)).toBeVisible(); + }); + }); + + // ============================================================================ + // AC4: Calendar with conforming dates + // ============================================================================ + test.describe('AC4: Calendar enforces conforming dates', () => { + test('modify date reopens form with conforming minimum date', async ({ page }) => { + seedHardRules(); + clearCache(); + + await loginAsTeacher(page); + await navigateToHomework(page); + await openCreateAndFillForm(page, 'Devoir modify date hard', 2); + + const blockedDialog = page.getByRole('alertdialog'); + await expect(blockedDialog).toBeVisible({ timeout: 10000 }); + + // Click "Modifier la date" + await blockedDialog.getByRole('button', { name: /modifier la date/i }).click(); + + // Form reopens + await expect(blockedDialog).not.toBeVisible({ timeout: 3000 }); + const createDialog = page.getByRole('dialog'); + await expect(createDialog).toBeVisible(); + + // Date input should have min attribute enforcing conforming dates + const dateInput = createDialog.locator('#hw-due-date'); + await expect(dateInput).toBeVisible(); + const minValue = await dateInput.getAttribute('min'); + expect(minValue).toBeTruthy(); + }); + + test('homework can be created with compliant date after modification', async ({ page }) => { + seedHardRules(); + clearCache(); + + await loginAsTeacher(page); + await navigateToHomework(page); + await openCreateAndFillForm(page, 'Devoir compliant hard', 2); + + const blockedDialog = page.getByRole('alertdialog'); + await expect(blockedDialog).toBeVisible({ timeout: 10000 }); + + // Click first suggested date + const firstSuggested = blockedDialog.getByRole('group', { name: /dates conformes suggérées/i }).getByRole('button').first(); + await firstSuggested.click(); + + // Submit with the compliant date + await page.getByRole('button', { name: /créer le devoir/i }).click(); + + // Homework created successfully + await expect(page.getByText('Devoir compliant hard')).toBeVisible({ timeout: 10000 }); + }); + }); +}); diff --git a/frontend/src/lib/components/molecules/RuleBlockedModal/RuleBlockedModal.svelte b/frontend/src/lib/components/molecules/RuleBlockedModal/RuleBlockedModal.svelte new file mode 100644 index 0000000..cdab6f7 --- /dev/null +++ b/frontend/src/lib/components/molecules/RuleBlockedModal/RuleBlockedModal.svelte @@ -0,0 +1,218 @@ + + + + + + diff --git a/frontend/src/routes/dashboard/teacher/homework/+page.svelte b/frontend/src/routes/dashboard/teacher/homework/+page.svelte index 76083a2..f377470 100644 --- a/frontend/src/routes/dashboard/teacher/homework/+page.svelte +++ b/frontend/src/routes/dashboard/teacher/homework/+page.svelte @@ -4,6 +4,7 @@ import { getApiBaseUrl } from '$lib/api/config'; import { authenticatedFetch, getAuthenticatedUserId } from '$lib/auth'; import Pagination from '$lib/components/molecules/Pagination/Pagination.svelte'; + import RuleBlockedModal from '$lib/components/molecules/RuleBlockedModal/RuleBlockedModal.svelte'; import SearchInput from '$lib/components/molecules/SearchInput/SearchInput.svelte'; import { untrack } from 'svelte'; @@ -94,11 +95,19 @@ let duplicateValidationResults = $state>([]); let duplicateWarnings = $state>([]); - // Rule warning modal + // Rule warning modal (soft mode) let showRuleWarningModal = $state(false); let ruleWarnings = $state([]); let ruleConformMinDate = $state(''); + // Rule blocked modal (hard mode) + let showRuleBlockedModal = $state(false); + let ruleBlockedWarnings = $state([]); + let ruleBlockedSuggestedDates = $state([]); + + // Inline date validation for hard mode + let dueDateError = $state(null); + // Class filter let filterClassId = $state(page.url.searchParams.get('classId') ?? ''); @@ -300,6 +309,7 @@ newDescription = ''; newDueDate = ''; ruleConformMinDate = ''; + dueDateError = null; } function closeCreateModal() { @@ -308,6 +318,8 @@ async function handleCreate(acknowledgeWarning = false) { if (!newClassId || !newSubjectId || !newTitle.trim() || !newDueDate) return; + dueDateError = validateDueDateLocally(newDueDate); + if (dueDateError) return; try { isSubmitting = true; @@ -326,6 +338,17 @@ }), }); + if (response.status === 422) { + const data = await response.json().catch(() => null); + if (data?.type === 'homework_rules_blocked' && Array.isArray(data.warnings)) { + ruleBlockedWarnings = data.warnings; + ruleBlockedSuggestedDates = Array.isArray(data.suggestedDates) ? data.suggestedDates : []; + showCreateModal = false; + showRuleBlockedModal = true; + return; + } + } + if (response.status === 409) { const data = await response.json().catch(() => null); if (data?.type === 'homework_rules_warning' && Array.isArray(data.warnings)) { @@ -349,6 +372,9 @@ closeCreateModal(); showRuleWarningModal = false; ruleWarnings = []; + showRuleBlockedModal = false; + ruleBlockedWarnings = []; + ruleBlockedSuggestedDates = []; await loadHomeworks(); } catch (e) { error = e instanceof Error ? e.message : 'Erreur lors de la création'; @@ -399,6 +425,44 @@ newDueDate = ruleConformMinDate; } + function validateDueDateLocally(dateStr: string): string | null { + if (!dateStr) return null; + const date = new Date(dateStr + 'T00:00:00'); + const day = date.getDay(); + if (day === 0 || day === 6) { + return 'Les devoirs ne peuvent pas être fixés un weekend.'; + } + if (ruleConformMinDate && dateStr < ruleConformMinDate) { + return 'Cette date ne respecte pas les règles de l\'établissement. Choisissez une date ultérieure.'; + } + return null; + } + + function handleDueDateChange(dateStr: string) { + newDueDate = dateStr; + dueDateError = validateDueDateLocally(dateStr); + } + + function handleBlockedSelectDate(date: string) { + showRuleBlockedModal = false; + ruleBlockedWarnings = []; + ruleBlockedSuggestedDates = []; + ruleConformMinDate = date; + newDueDate = date; + showCreateModal = true; + } + + function handleBlockedClose() { + const firstSuggested = ruleBlockedSuggestedDates[0]; + const conformDate = firstSuggested ?? computeConformMinDate(ruleBlockedWarnings); + showRuleBlockedModal = false; + ruleBlockedWarnings = []; + ruleBlockedSuggestedDates = []; + ruleConformMinDate = conformDate; + newDueDate = conformDate; + showCreateModal = true; + } + // --- Edit --- function openEditModal(hw: Homework) { editHomework = hw; @@ -779,8 +843,17 @@
- - {#if ruleConformMinDate} + handleDueDateChange((e.target as HTMLInputElement).value)} + required + min={ruleConformMinDate || minDueDate} + /> + {#if dueDateError} + {dueDateError} + {:else if ruleConformMinDate} Date minimale conforme aux règles : {new Date(ruleConformMinDate + 'T00:00:00').toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })} @@ -1078,6 +1151,16 @@
{/if} + +{#if showRuleBlockedModal && ruleBlockedWarnings.length > 0} + +{/if} +