feat: Permettre aux enseignants de contourner les règles de devoirs avec justification
Akeneo permet de configurer des règles de devoirs en mode Hard qui bloquent totalement la création. Or certains cas légitimes (sorties scolaires, événements exceptionnels) nécessitent de passer outre ces règles. Sans mécanisme d'exception, l'enseignant est bloqué et doit contacter manuellement la direction. Cette implémentation ajoute un flux complet d'exception : l'enseignant justifie sa demande (min 20 caractères), le devoir est créé immédiatement, et la direction est notifiée par email. Le handler vérifie côté serveur que les règles sont réellement bloquantes avant d'accepter l'exception, empêchant toute fabrication de fausses exceptions via l'API. La direction dispose d'un rapport filtrable par période, enseignant et type de règle.
This commit is contained in:
365
frontend/e2e/homework-exception.spec.ts
Normal file
365
frontend/e2e/homework-exception.spec.ts
Normal file
@@ -0,0 +1,365 @@
|
||||
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-exception-teacher@example.com';
|
||||
const TEACHER_PASSWORD = 'Exception123';
|
||||
const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||
|
||||
const projectRoot = join(__dirname, '../..');
|
||||
const composeFile = join(projectRoot, 'compose.yaml');
|
||||
|
||||
function runSql(sql: string) {
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "${sql}" 2>&1`,
|
||||
{ encoding: 'utf-8' },
|
||||
);
|
||||
}
|
||||
|
||||
function clearCache() {
|
||||
try {
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear paginated_queries.cache 2>&1`,
|
||||
{ encoding: 'utf-8' },
|
||||
);
|
||||
} catch {
|
||||
// Cache pool may not exist
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDeterministicIds(): { schoolId: string; academicYearId: string } {
|
||||
const output = execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php -r '` +
|
||||
`require "/app/vendor/autoload.php"; ` +
|
||||
`$t="${TENANT_ID}"; $ns="6ba7b814-9dad-11d1-80b4-00c04fd430c8"; ` +
|
||||
`echo Ramsey\\Uuid\\Uuid::uuid5($ns,"school-$t")->toString()."\\n"; ` +
|
||||
`$m=(int)date("n"); $s=$m>=9?(int)date("Y"):(int)date("Y")-1; $e=$s+1; ` +
|
||||
`echo Ramsey\\Uuid\\Uuid::uuid5($ns,"$t:$s-$e")->toString();` +
|
||||
`' 2>&1`,
|
||||
{ encoding: 'utf-8' },
|
||||
).trim();
|
||||
const [schoolId, academicYearId] = output.split('\n');
|
||||
return { schoolId: schoolId!, academicYearId: academicYearId! };
|
||||
}
|
||||
|
||||
function getNextWeekday(daysFromNow: number): string {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() + daysFromNow);
|
||||
const day = date.getDay();
|
||||
if (day === 0) date.setDate(date.getDate() + 1);
|
||||
if (day === 6) date.setDate(date.getDate() + 2);
|
||||
const y = date.getFullYear();
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(date.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
async function 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
|
||||
}
|
||||
}
|
||||
|
||||
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 Exception Request (Story 5.6)', () => {
|
||||
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 --firstName=Jean --lastName=Exception 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-EX-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-EX-Maths', 'E2EEXM', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`,
|
||||
);
|
||||
} catch {
|
||||
// May already exist
|
||||
}
|
||||
|
||||
seedTeacherAssignments();
|
||||
});
|
||||
|
||||
test.describe.configure({ mode: 'serial' });
|
||||
|
||||
test.beforeEach(async () => {
|
||||
try {
|
||||
runSql(
|
||||
`DELETE FROM homework_rule_exceptions WHERE tenant_id = '${TENANT_ID}'`,
|
||||
);
|
||||
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 {
|
||||
// Tables may not exist
|
||||
}
|
||||
|
||||
clearRules();
|
||||
clearCache();
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// AC1: Exception request form
|
||||
// ============================================================================
|
||||
test.describe('AC1: Exception request form', () => {
|
||||
test('shows "Demander une exception" button in blocking modal', async ({ page }) => {
|
||||
seedHardRules();
|
||||
clearCache();
|
||||
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
await openCreateAndFillForm(page, 'Devoir exception test', 2);
|
||||
|
||||
const blockedDialog = page.getByRole('alertdialog');
|
||||
await expect(blockedDialog).toBeVisible({ timeout: 10000 });
|
||||
|
||||
await expect(blockedDialog.getByRole('button', { name: /demander une exception/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('clicking exception button opens justification form', async ({ page }) => {
|
||||
seedHardRules();
|
||||
clearCache();
|
||||
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
await openCreateAndFillForm(page, 'Devoir exception form', 2);
|
||||
|
||||
const blockedDialog = page.getByRole('alertdialog');
|
||||
await expect(blockedDialog).toBeVisible({ timeout: 10000 });
|
||||
|
||||
await blockedDialog.getByRole('button', { name: /demander une exception/i }).click();
|
||||
|
||||
// Exception request dialog appears
|
||||
const exceptionDialog = page.getByRole('dialog');
|
||||
await expect(exceptionDialog).toBeVisible({ timeout: 5000 });
|
||||
await expect(exceptionDialog.getByText(/demander une exception/i)).toBeVisible();
|
||||
await expect(exceptionDialog.locator('#exception-justification')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// AC2: Justification required (min 20 chars)
|
||||
// ============================================================================
|
||||
test.describe('AC2: Justification validation', () => {
|
||||
test('submit button disabled when justification too short', async ({ page }) => {
|
||||
seedHardRules();
|
||||
clearCache();
|
||||
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
await openCreateAndFillForm(page, 'Devoir justif short', 2);
|
||||
|
||||
const blockedDialog = page.getByRole('alertdialog');
|
||||
await expect(blockedDialog).toBeVisible({ timeout: 10000 });
|
||||
await blockedDialog.getByRole('button', { name: /demander une exception/i }).click();
|
||||
|
||||
const exceptionDialog = page.getByRole('dialog');
|
||||
await expect(exceptionDialog).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Type less than 20 characters
|
||||
await exceptionDialog.locator('#exception-justification').fill('Court');
|
||||
|
||||
// Submit button should be disabled
|
||||
await expect(exceptionDialog.getByRole('button', { name: /créer avec exception/i })).toBeDisabled();
|
||||
});
|
||||
|
||||
test('homework created immediately after valid justification', async ({ page }) => {
|
||||
seedHardRules();
|
||||
clearCache();
|
||||
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
await openCreateAndFillForm(page, 'Devoir exception créé', 2);
|
||||
|
||||
const blockedDialog = page.getByRole('alertdialog');
|
||||
await expect(blockedDialog).toBeVisible({ timeout: 10000 });
|
||||
await blockedDialog.getByRole('button', { name: /demander une exception/i }).click();
|
||||
|
||||
const exceptionDialog = page.getByRole('dialog');
|
||||
await expect(exceptionDialog).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Type valid justification (>= 20 chars)
|
||||
await exceptionDialog
|
||||
.locator('#exception-justification')
|
||||
.fill('Sortie scolaire prévue, les élèves doivent préparer leur dossier.');
|
||||
|
||||
// Submit
|
||||
await exceptionDialog.getByRole('button', { name: /créer avec exception/i }).click();
|
||||
|
||||
// Homework appears in the list
|
||||
await expect(page.getByText('Devoir exception créé')).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// AC4: Exception badge
|
||||
// ============================================================================
|
||||
test.describe('AC4: Exception marking', () => {
|
||||
test('homework with exception shows exception badge', async ({ page }) => {
|
||||
seedHardRules();
|
||||
clearCache();
|
||||
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
await openCreateAndFillForm(page, 'Devoir avec badge', 2);
|
||||
|
||||
const blockedDialog = page.getByRole('alertdialog');
|
||||
await expect(blockedDialog).toBeVisible({ timeout: 10000 });
|
||||
await blockedDialog.getByRole('button', { name: /demander une exception/i }).click();
|
||||
|
||||
const exceptionDialog = page.getByRole('dialog');
|
||||
await expect(exceptionDialog).toBeVisible({ timeout: 5000 });
|
||||
await exceptionDialog
|
||||
.locator('#exception-justification')
|
||||
.fill('Justification suffisamment longue pour être valide.');
|
||||
await exceptionDialog.getByRole('button', { name: /créer avec exception/i }).click();
|
||||
|
||||
// Wait for homework to appear
|
||||
await expect(page.getByText('Devoir avec badge')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Exception badge visible (⚠ Exception text or rule override badge)
|
||||
const card = page.locator('.homework-card', { hasText: 'Devoir avec badge' });
|
||||
await expect(card.locator('.badge-rule-exception, .badge-rule-override')).toBeVisible();
|
||||
});
|
||||
|
||||
test('exception badge tooltip describes the exception for justification viewing', async ({ page }) => {
|
||||
seedHardRules();
|
||||
clearCache();
|
||||
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
await openCreateAndFillForm(page, 'Devoir tooltip test', 2);
|
||||
|
||||
const blockedDialog = page.getByRole('alertdialog');
|
||||
await expect(blockedDialog).toBeVisible({ timeout: 10000 });
|
||||
await blockedDialog.getByRole('button', { name: /demander une exception/i }).click();
|
||||
|
||||
const exceptionDialog = page.getByRole('dialog');
|
||||
await expect(exceptionDialog).toBeVisible({ timeout: 5000 });
|
||||
await exceptionDialog
|
||||
.locator('#exception-justification')
|
||||
.fill('Sortie scolaire prévue, les élèves doivent préparer leur dossier.');
|
||||
await exceptionDialog.getByRole('button', { name: /créer avec exception/i }).click();
|
||||
|
||||
await expect(page.getByText('Devoir tooltip test')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Badge has descriptive title attribute for justification consultation
|
||||
const card = page.locator('.homework-card', { hasText: 'Devoir tooltip test' });
|
||||
const badge = card.locator('.badge-rule-exception');
|
||||
await expect(badge).toBeVisible();
|
||||
await expect(badge).toHaveAttribute('title', /exception/i);
|
||||
await expect(badge).toContainText('Exception');
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// AC5: Direction exceptions report
|
||||
// ============================================================================
|
||||
// AC5 (Direction exceptions report) is covered by unit tests
|
||||
// (GetHomeworkExceptionsReportHandlerTest) because E2E testing requires
|
||||
// multi-tenant admin login which is not reliably testable in parallel mode.
|
||||
|
||||
// ============================================================================
|
||||
// AC6: Soft mode - no justification needed
|
||||
// ============================================================================
|
||||
test.describe('AC6: Soft mode without justification', () => {
|
||||
test('soft mode does not show exception request button', async ({ page }) => {
|
||||
// Configure soft mode
|
||||
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, 'soft', true, NOW(), NOW()) ` +
|
||||
`ON CONFLICT (tenant_id) DO UPDATE SET rules = '${rulesJson}'::jsonb, enforcement_mode = 'soft', enabled = true, updated_at = NOW()`,
|
||||
);
|
||||
clearCache();
|
||||
|
||||
await loginAsTeacher(page);
|
||||
await navigateToHomework(page);
|
||||
await openCreateAndFillForm(page, 'Devoir soft mode', 2);
|
||||
|
||||
// Warning modal appears (not blocking)
|
||||
const warningModal = page.getByRole('alertdialog');
|
||||
await expect(warningModal).toBeVisible({ timeout: 10000 });
|
||||
await expect(warningModal.getByText(/avertissement/i)).toBeVisible();
|
||||
|
||||
// "Continuer malgré tout" visible (soft mode allows bypass)
|
||||
await expect(warningModal.getByRole('button', { name: /continuer malgré tout/i })).toBeVisible();
|
||||
|
||||
// No exception request button
|
||||
await expect(warningModal.getByRole('button', { name: /demander une exception/i })).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -276,7 +276,7 @@ test.describe('Homework Rules - Hard Mode Blocking (Story 5.5)', () => {
|
||||
// AC3: Exception request information
|
||||
// ============================================================================
|
||||
test.describe('AC3: Exception request information', () => {
|
||||
test('shows exception contact information in blocking modal', async ({ page }) => {
|
||||
test('shows exception request button in blocking modal', async ({ page }) => {
|
||||
seedHardRules();
|
||||
clearCache();
|
||||
|
||||
@@ -287,8 +287,8 @@ test.describe('Homework Rules - Hard Mode Blocking (Story 5.5)', () => {
|
||||
const blockedDialog = page.getByRole('alertdialog');
|
||||
await expect(blockedDialog).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Exception information visible
|
||||
await expect(blockedDialog.getByText(/exception.*contactez/i)).toBeVisible();
|
||||
// Exception request button visible (Story 5.6 replaced static text with a real button)
|
||||
await expect(blockedDialog.getByRole('button', { name: /demander une exception/i })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
<script lang="ts">
|
||||
interface RuleWarning {
|
||||
ruleType: string;
|
||||
message: string;
|
||||
params: Record<string, unknown>;
|
||||
}
|
||||
|
||||
let {
|
||||
warnings,
|
||||
onSubmit,
|
||||
onClose,
|
||||
isSubmitting = false,
|
||||
}: {
|
||||
warnings: RuleWarning[];
|
||||
onSubmit: (justification: string, ruleTypes: string[]) => void;
|
||||
onClose: () => void;
|
||||
isSubmitting?: boolean;
|
||||
} = $props();
|
||||
|
||||
let justification = $state('');
|
||||
let charCount = $derived(justification.length);
|
||||
let isValid = $derived(charCount >= 20);
|
||||
|
||||
let modalElement = $state<HTMLDivElement | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
if (modalElement) {
|
||||
modalElement.focus();
|
||||
}
|
||||
});
|
||||
|
||||
function handleSubmit() {
|
||||
if (!isValid || isSubmitting) return;
|
||||
const ruleTypes = warnings.map((w) => w.ruleType);
|
||||
onSubmit(justification, ruleTypes);
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="modal-overlay" onclick={onClose} role="presentation">
|
||||
<div
|
||||
bind:this={modalElement}
|
||||
class="modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="exception-request-title"
|
||||
aria-describedby="exception-request-description"
|
||||
tabindex="-1"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
}}
|
||||
>
|
||||
<header class="modal-header modal-header-exception">
|
||||
<h2 id="exception-request-title">Demander une exception</h2>
|
||||
<button class="modal-close" onclick={onClose} aria-label="Fermer">×</button>
|
||||
</header>
|
||||
|
||||
<form
|
||||
class="modal-body"
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}}
|
||||
>
|
||||
<p id="exception-request-description">
|
||||
Ce devoir enfreint les règles suivantes. Veuillez justifier votre demande d'exception :
|
||||
</p>
|
||||
|
||||
<ul class="rule-list">
|
||||
{#each warnings as warning}
|
||||
<li class="rule-item">
|
||||
<span class="rule-icon">🚫</span>
|
||||
<span>{warning.message}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="exception-justification">
|
||||
Justification <span class="required">*</span>
|
||||
</label>
|
||||
<textarea
|
||||
id="exception-justification"
|
||||
bind:value={justification}
|
||||
placeholder="Expliquez pourquoi ce devoir nécessite une exception aux règles..."
|
||||
rows="4"
|
||||
minlength="20"
|
||||
required
|
||||
></textarea>
|
||||
<div class="char-counter" class:char-counter-valid={isValid} class:char-counter-invalid={charCount > 0 && !isValid}>
|
||||
{charCount}/20 caractères minimum
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="exception-notice">
|
||||
Le devoir sera créé immédiatement. La direction sera notifiée de cette exception.
|
||||
</p>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn-secondary" onclick={onClose} disabled={isSubmitting}>
|
||||
Annuler
|
||||
</button>
|
||||
<button type="submit" class="btn-primary" disabled={!isValid || isSubmitting}>
|
||||
{#if isSubmitting}
|
||||
Création...
|
||||
{:else}
|
||||
Créer avec exception
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: white;
|
||||
border-radius: 0.75rem;
|
||||
width: 100%;
|
||||
max-width: 32rem;
|
||||
max-height: 90vh;
|
||||
overflow: auto;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
|
||||
.modal-header-exception {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.5rem;
|
||||
background: #f59e0b;
|
||||
color: white;
|
||||
border-radius: 0.75rem 0.75rem 0 0;
|
||||
}
|
||||
|
||||
.modal-header-exception h2 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.rule-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.rule-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
background: #fef2f2;
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
.rule-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
font-weight: 500;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.required {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 0.75rem 1rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 1rem;
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
transition: border-color 0.2s;
|
||||
}
|
||||
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: #f59e0b;
|
||||
box-shadow: 0 0 0 3px rgba(245, 158, 11, 0.1);
|
||||
}
|
||||
|
||||
.char-counter {
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.char-counter-valid {
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.char-counter-invalid {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.exception-notice {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
font-style: italic;
|
||||
margin: 1rem 0 0;
|
||||
padding: 0.75rem;
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fde68a;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 0.625rem 1.25rem;
|
||||
background: #f59e0b;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #d97706;
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 0.625rem 1.25rem;
|
||||
background: white;
|
||||
color: #374151;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.375rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
</style>
|
||||
@@ -10,11 +10,13 @@
|
||||
suggestedDates = [],
|
||||
onSelectDate,
|
||||
onClose,
|
||||
onRequestException,
|
||||
}: {
|
||||
warnings: RuleWarning[];
|
||||
suggestedDates: string[];
|
||||
onSelectDate: (date: string) => void;
|
||||
onClose: () => void;
|
||||
onRequestException?: () => void;
|
||||
} = $props();
|
||||
|
||||
let modalElement = $state<HTMLDivElement | null>(null);
|
||||
@@ -86,11 +88,13 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<p class="rule-blocked-exception">
|
||||
<span class="exception-link-placeholder">
|
||||
Besoin d'une exception ? Contactez votre administration.
|
||||
</span>
|
||||
</p>
|
||||
{#if onRequestException}
|
||||
<div class="rule-blocked-exception">
|
||||
<button type="button" class="btn-exception" onclick={onRequestException}>
|
||||
Demander une exception
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
@@ -202,10 +206,39 @@
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.exception-link-placeholder {
|
||||
color: #6b7280;
|
||||
.btn-exception {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: #fffbeb;
|
||||
color: #92400e;
|
||||
border: 1px solid #fde68a;
|
||||
border-radius: 0.375rem;
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
font-style: italic;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-exception:hover {
|
||||
background: #fef3c7;
|
||||
border-color: #f59e0b;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
padding: 0.625rem 1.25rem;
|
||||
background: white;
|
||||
color: #374151;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.375rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
|
||||
@@ -68,7 +68,8 @@
|
||||
{ href: '/admin/image-rights', label: "Droit à l'image" },
|
||||
{ href: '/admin/pedagogy', label: 'Pédagogie' },
|
||||
{ href: '/admin/branding', label: 'Identité visuelle' },
|
||||
{ href: '/admin/homework-rules', label: 'Règles de devoirs' }
|
||||
{ href: '/admin/homework-rules', label: 'Règles de devoirs' },
|
||||
{ href: '/admin/homework-exceptions', label: 'Exceptions devoirs' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
468
frontend/src/routes/admin/homework-exceptions/+page.svelte
Normal file
468
frontend/src/routes/admin/homework-exceptions/+page.svelte
Normal file
@@ -0,0 +1,468 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { getApiBaseUrl } from '$lib/api/config';
|
||||
import { authenticatedFetch } from '$lib/auth';
|
||||
import { untrack } from 'svelte';
|
||||
|
||||
interface HomeworkException {
|
||||
id: string;
|
||||
homeworkId: string;
|
||||
homeworkTitle: string;
|
||||
ruleType: string;
|
||||
justification: string;
|
||||
teacherId: string;
|
||||
teacherName: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
let exceptions = $state<HomeworkException[]>([]);
|
||||
let isLoading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
let filterTeacherId = $state(page.url.searchParams.get('teacherId') ?? '');
|
||||
let filterRuleType = $state(page.url.searchParams.get('ruleType') ?? '');
|
||||
let filterStartDate = $state(page.url.searchParams.get('startDate') ?? '');
|
||||
let filterEndDate = $state(page.url.searchParams.get('endDate') ?? '');
|
||||
|
||||
$effect(() => {
|
||||
untrack(() => loadExceptions());
|
||||
});
|
||||
|
||||
function extractCollection<T>(data: Record<string, unknown>): T[] {
|
||||
const members = data['hydra:member'] ?? data['member'];
|
||||
if (Array.isArray(members)) return members as T[];
|
||||
if (Array.isArray(data)) return data as T[];
|
||||
return [];
|
||||
}
|
||||
|
||||
async function loadExceptions() {
|
||||
try {
|
||||
isLoading = true;
|
||||
error = null;
|
||||
const apiUrl = getApiBaseUrl();
|
||||
const params = new URLSearchParams();
|
||||
if (filterStartDate) params.set('startDate', filterStartDate);
|
||||
if (filterEndDate) params.set('endDate', filterEndDate);
|
||||
if (filterTeacherId) params.set('teacherId', filterTeacherId);
|
||||
if (filterRuleType) params.set('ruleType', filterRuleType);
|
||||
|
||||
const response = await authenticatedFetch(`${apiUrl}/admin/homework-exceptions?${params.toString()}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Erreur lors du chargement des exceptions');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
exceptions = extractCollection<HomeworkException>(data);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Erreur inconnue';
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function updateUrl() {
|
||||
const params = new URLSearchParams();
|
||||
if (filterStartDate) params.set('startDate', filterStartDate);
|
||||
if (filterEndDate) params.set('endDate', filterEndDate);
|
||||
if (filterTeacherId) params.set('teacherId', filterTeacherId);
|
||||
if (filterRuleType) params.set('ruleType', filterRuleType);
|
||||
const query = params.toString();
|
||||
goto(`?${query}`, { replaceState: true, noScroll: true, keepFocus: true });
|
||||
}
|
||||
|
||||
function handleFilter() {
|
||||
updateUrl();
|
||||
loadExceptions();
|
||||
}
|
||||
|
||||
function handleClearFilters() {
|
||||
filterStartDate = '';
|
||||
filterEndDate = '';
|
||||
filterTeacherId = '';
|
||||
filterRuleType = '';
|
||||
updateUrl();
|
||||
loadExceptions();
|
||||
}
|
||||
|
||||
function formatDate(dateStr: string): string {
|
||||
return new Date(dateStr).toLocaleDateString('fr-FR', {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
|
||||
function formatRuleType(ruleType: string): string {
|
||||
const labels: Record<string, string> = {
|
||||
minimum_delay: 'Délai minimum',
|
||||
no_monday_after: 'Pas de lundi après',
|
||||
};
|
||||
return ruleType
|
||||
.split(',')
|
||||
.map((t) => labels[t] ?? t)
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
// Derive unique teachers from loaded exceptions for the filter dropdown
|
||||
let uniqueTeachers = $derived.by(() => {
|
||||
const seen = new Map<string, string>();
|
||||
for (const ex of exceptions) {
|
||||
if (!seen.has(ex.teacherId)) {
|
||||
seen.set(ex.teacherId, ex.teacherName);
|
||||
}
|
||||
}
|
||||
return [...seen.entries()].map(([id, name]) => ({ id, name }));
|
||||
});
|
||||
|
||||
let hasFilters = $derived(
|
||||
filterStartDate !== '' || filterEndDate !== '' || filterTeacherId !== '' || filterRuleType !== '',
|
||||
);
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Exceptions aux règles de devoirs - Classeo</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="exceptions-page">
|
||||
<header class="page-header">
|
||||
<div class="header-content">
|
||||
<h1>Exceptions aux règles de devoirs</h1>
|
||||
<p class="subtitle">Rapport des contournements de règles par les enseignants</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if error}
|
||||
<div class="alert alert-error">
|
||||
<span class="alert-icon">⚠</span>
|
||||
{error}
|
||||
<button class="alert-close" onclick={() => (error = null)}>×</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="filters-section">
|
||||
<div class="filters-row">
|
||||
<div class="filter-group">
|
||||
<label for="filter-start">Du</label>
|
||||
<input
|
||||
type="date"
|
||||
id="filter-start"
|
||||
bind:value={filterStartDate}
|
||||
onchange={handleFilter}
|
||||
/>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label for="filter-end">Au</label>
|
||||
<input
|
||||
type="date"
|
||||
id="filter-end"
|
||||
bind:value={filterEndDate}
|
||||
onchange={handleFilter}
|
||||
/>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label for="filter-teacher">Enseignant</label>
|
||||
<select id="filter-teacher" bind:value={filterTeacherId} onchange={handleFilter}>
|
||||
<option value="">Tous les enseignants</option>
|
||||
{#each uniqueTeachers as teacher (teacher.id)}
|
||||
<option value={teacher.id}>{teacher.name}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
<div class="filter-group">
|
||||
<label for="filter-rule">Règle</label>
|
||||
<select id="filter-rule" bind:value={filterRuleType} onchange={handleFilter}>
|
||||
<option value="">Toutes les règles</option>
|
||||
<option value="minimum_delay">Délai minimum</option>
|
||||
<option value="no_monday_after">Pas de lundi après</option>
|
||||
</select>
|
||||
</div>
|
||||
{#if hasFilters}
|
||||
<button class="btn-clear" onclick={handleClearFilters}>Effacer les filtres</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if isLoading}
|
||||
<div class="loading-state" aria-live="polite" role="status">
|
||||
<div class="spinner"></div>
|
||||
<p>Chargement des exceptions...</p>
|
||||
</div>
|
||||
{:else if exceptions.length === 0}
|
||||
<div class="empty-state">
|
||||
<span class="empty-icon">✅</span>
|
||||
<h2>Aucune exception</h2>
|
||||
<p>
|
||||
{#if hasFilters}
|
||||
Aucune exception ne correspond à vos critères de recherche.
|
||||
{:else}
|
||||
Aucun enseignant n'a demandé d'exception aux règles de devoirs.
|
||||
{/if}
|
||||
</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="results-summary">
|
||||
{exceptions.length} exception{exceptions.length > 1 ? 's' : ''} trouvée{exceptions.length > 1 ? 's' : ''}
|
||||
</div>
|
||||
<div class="exceptions-list">
|
||||
{#each exceptions as ex (ex.id)}
|
||||
<div class="exception-card">
|
||||
<div class="exception-header">
|
||||
<h3 class="exception-homework">{ex.homeworkTitle}</h3>
|
||||
<span class="exception-rule">{formatRuleType(ex.ruleType)}</span>
|
||||
</div>
|
||||
<div class="exception-meta">
|
||||
<span class="meta-item" title="Enseignant">
|
||||
<span class="meta-icon">👤</span>
|
||||
{ex.teacherName}
|
||||
</span>
|
||||
<span class="meta-item" title="Date de l'exception">
|
||||
<span class="meta-icon">📅</span>
|
||||
{formatDate(ex.createdAt)}
|
||||
</span>
|
||||
</div>
|
||||
<div class="exception-justification">
|
||||
<span class="justification-label">Justification :</span>
|
||||
<p class="justification-text">{ex.justification}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.exceptions-page {
|
||||
padding: 1.5rem;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.header-content h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0.25rem 0 0;
|
||||
color: #6b7280;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.alert {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.alert-error {
|
||||
background: #fef2f2;
|
||||
border: 1px solid #fecaca;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.alert-icon {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.alert-close {
|
||||
margin-left: auto;
|
||||
padding: 0.25rem 0.5rem;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.25rem;
|
||||
cursor: pointer;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.filters-section {
|
||||
background: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.75rem;
|
||||
padding: 1rem 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.filters-row {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
align-items: flex-end;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.filter-group label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #6b7280;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.filter-group input,
|
||||
.filter-group select {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.btn-clear {
|
||||
padding: 0.5rem 1rem;
|
||||
background: #f3f4f6;
|
||||
color: #374151;
|
||||
border: 1px solid #d1d5db;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-clear:hover {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.loading-state,
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3rem;
|
||||
text-align: center;
|
||||
background: white;
|
||||
border-radius: 0.75rem;
|
||||
border: 2px dashed #e5e7eb;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 3px solid #e5e7eb;
|
||||
border-top-color: #3b82f6;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.empty-state h2 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.25rem;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 0;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.results-summary {
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.exceptions-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.exception-card {
|
||||
padding: 1.25rem;
|
||||
background: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.75rem;
|
||||
border-left: 4px solid #f59e0b;
|
||||
}
|
||||
|
||||
.exception-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.exception-homework {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.exception-rule {
|
||||
flex-shrink: 0;
|
||||
padding: 0.125rem 0.5rem;
|
||||
background: #fffbeb;
|
||||
color: #92400e;
|
||||
border: 1px solid #fde68a;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.exception-meta {
|
||||
display: flex;
|
||||
gap: 1.25rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.meta-icon {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.exception-justification {
|
||||
background: #f9fafb;
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.75rem 1rem;
|
||||
}
|
||||
|
||||
.justification-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #6b7280;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.justification-text {
|
||||
margin: 0.25rem 0 0;
|
||||
font-size: 0.875rem;
|
||||
color: #374151;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
@@ -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 ExceptionRequestModal from '$lib/components/molecules/ExceptionRequestModal/ExceptionRequestModal.svelte';
|
||||
import RuleBlockedModal from '$lib/components/molecules/RuleBlockedModal/RuleBlockedModal.svelte';
|
||||
import SearchInput from '$lib/components/molecules/SearchInput/SearchInput.svelte';
|
||||
import { untrack } from 'svelte';
|
||||
@@ -20,6 +21,9 @@
|
||||
className: string | null;
|
||||
subjectName: string | null;
|
||||
hasRuleOverride: boolean;
|
||||
hasRuleException?: boolean;
|
||||
ruleExceptionJustification?: string | null;
|
||||
ruleExceptionRuleType?: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -105,6 +109,15 @@
|
||||
let ruleBlockedWarnings = $state<RuleWarning[]>([]);
|
||||
let ruleBlockedSuggestedDates = $state<string[]>([]);
|
||||
|
||||
// Exception justification viewing
|
||||
let showJustificationModal = $state(false);
|
||||
let justificationHomework = $state<Homework | null>(null);
|
||||
|
||||
// Exception request modal
|
||||
let showExceptionModal = $state(false);
|
||||
let exceptionWarnings = $state<RuleWarning[]>([]);
|
||||
let isSubmittingException = $state(false);
|
||||
|
||||
// Inline date validation for hard mode
|
||||
let dueDateError = $state<string | null>(null);
|
||||
|
||||
@@ -452,6 +465,55 @@
|
||||
showCreateModal = true;
|
||||
}
|
||||
|
||||
function handleRequestException() {
|
||||
exceptionWarnings = ruleBlockedWarnings;
|
||||
showRuleBlockedModal = false;
|
||||
showExceptionModal = true;
|
||||
}
|
||||
|
||||
async function handleExceptionSubmit(justification: string, ruleTypes: string[]) {
|
||||
if (!newClassId || !newSubjectId || !newTitle.trim() || !newDueDate) return;
|
||||
|
||||
try {
|
||||
isSubmittingException = true;
|
||||
error = null;
|
||||
const apiUrl = getApiBaseUrl();
|
||||
const response = await authenticatedFetch(`${apiUrl}/homework/with-exception`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
classId: newClassId,
|
||||
subjectId: newSubjectId,
|
||||
title: newTitle.trim(),
|
||||
description: newDescription.trim() || null,
|
||||
dueDate: newDueDate,
|
||||
justification,
|
||||
ruleTypes,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => null);
|
||||
const msg =
|
||||
errorData?.['hydra:description'] ??
|
||||
errorData?.message ??
|
||||
errorData?.detail ??
|
||||
`Erreur lors de la création avec exception (${response.status})`;
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
showExceptionModal = false;
|
||||
exceptionWarnings = [];
|
||||
ruleBlockedWarnings = [];
|
||||
ruleBlockedSuggestedDates = [];
|
||||
await loadHomeworks();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Erreur lors de la création avec exception';
|
||||
} finally {
|
||||
isSubmittingException = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleBlockedClose() {
|
||||
const firstSuggested = ruleBlockedSuggestedDates[0];
|
||||
const conformDate = firstSuggested ?? computeConformMinDate(ruleBlockedWarnings);
|
||||
@@ -717,7 +779,14 @@
|
||||
<div class="homework-header">
|
||||
<h3 class="homework-title">{hw.title}</h3>
|
||||
<div class="homework-badges">
|
||||
{#if hw.hasRuleOverride}
|
||||
{#if hw.hasRuleException}
|
||||
<button
|
||||
type="button"
|
||||
class="badge-rule-exception"
|
||||
title="Créé avec une exception aux règles — cliquer pour voir la justification"
|
||||
onclick={() => { justificationHomework = hw; showJustificationModal = true; }}
|
||||
>⚠ Exception</button>
|
||||
{:else if hw.hasRuleOverride}
|
||||
<span class="badge-rule-override" title="Créé malgré un avertissement de règle">⚠</span>
|
||||
{/if}
|
||||
<span class="homework-status" class:status-published={hw.status === 'published'} class:status-deleted={hw.status === 'deleted'}>
|
||||
@@ -1158,9 +1227,63 @@
|
||||
suggestedDates={ruleBlockedSuggestedDates}
|
||||
onSelectDate={handleBlockedSelectDate}
|
||||
onClose={handleBlockedClose}
|
||||
onRequestException={handleRequestException}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Exception Request Modal -->
|
||||
{#if showExceptionModal && exceptionWarnings.length > 0}
|
||||
<ExceptionRequestModal
|
||||
warnings={exceptionWarnings}
|
||||
onSubmit={handleExceptionSubmit}
|
||||
onClose={() => {
|
||||
showExceptionModal = false;
|
||||
exceptionWarnings = [];
|
||||
}}
|
||||
isSubmitting={isSubmittingException}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
<!-- Justification Viewing Modal -->
|
||||
{#if showJustificationModal && justificationHomework}
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div class="modal-overlay" onclick={() => { showJustificationModal = false; justificationHomework = null; }} role="presentation">
|
||||
<div
|
||||
class="modal modal-confirm"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="justification-title"
|
||||
tabindex="-1"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
onkeydown={(e) => { if (e.key === 'Escape') { showJustificationModal = false; justificationHomework = null; } }}
|
||||
>
|
||||
<header class="modal-header modal-header-exception-view">
|
||||
<h2 id="justification-title">Exception aux règles</h2>
|
||||
<button class="modal-close" onclick={() => { showJustificationModal = false; justificationHomework = null; }} aria-label="Fermer">×</button>
|
||||
</header>
|
||||
|
||||
<div class="modal-body">
|
||||
<div class="justification-info">
|
||||
<span class="justification-info-label">Devoir :</span>
|
||||
<span>{justificationHomework.title}</span>
|
||||
<span class="justification-info-label">Règle contournée :</span>
|
||||
<span>{justificationHomework.ruleExceptionRuleType ?? 'N/A'}</span>
|
||||
</div>
|
||||
<div class="justification-content">
|
||||
<span class="justification-content-label">Justification :</span>
|
||||
<p class="justification-content-text">{justificationHomework.ruleExceptionJustification ?? 'Non disponible'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn-secondary" onclick={() => { showJustificationModal = false; justificationHomework = null; }}>
|
||||
Fermer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.homework-page {
|
||||
padding: 1.5rem;
|
||||
@@ -1713,6 +1836,73 @@
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.badge-rule-exception {
|
||||
font-size: 0.7rem;
|
||||
color: #92400e;
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fde68a;
|
||||
border-radius: 9999px;
|
||||
padding: 0.125rem 0.5rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.badge-rule-exception:hover {
|
||||
background: #fef3c7;
|
||||
border-color: #f59e0b;
|
||||
}
|
||||
|
||||
/* Justification viewing modal */
|
||||
.modal-header-exception-view {
|
||||
background: #f59e0b;
|
||||
color: white;
|
||||
border-radius: 0.75rem 0.75rem 0 0;
|
||||
}
|
||||
|
||||
.modal-header-exception-view h2 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.justification-info {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.25rem 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
margin-bottom: 1rem;
|
||||
background: #f9fafb;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.justification-info-label {
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
|
||||
.justification-content {
|
||||
padding: 0.75rem 1rem;
|
||||
background: #fffbeb;
|
||||
border: 1px solid #fde68a;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.justification-content-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #92400e;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.justification-content-text {
|
||||
margin: 0.25rem 0 0;
|
||||
font-size: 0.875rem;
|
||||
color: #374151;
|
||||
line-height: 1.5;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* Rule Warning Modal */
|
||||
.modal-header-warning {
|
||||
border-bottom: 3px solid #f59e0b;
|
||||
|
||||
Reference in New Issue
Block a user