feat: Permettre au parent de consulter les devoirs de ses enfants
Les parents avaient accès à l'emploi du temps de leurs enfants mais pas à leurs devoirs. Sans cette visibilité, ils ne pouvaient pas accompagner efficacement le travail scolaire à la maison, notamment identifier les devoirs urgents ou contacter l'enseignant en cas de besoin. Le parent dispose désormais d'une vue consolidée multi-enfants avec filtrage par enfant et par matière, badges d'urgence différenciés (en retard / aujourd'hui / pour demain), lien de contact enseignant pré-rempli, et cache offline scopé par utilisateur.
This commit is contained in:
479
frontend/e2e/parent-homework.spec.ts
Normal file
479
frontend/e2e/parent-homework.spec.ts
Normal file
@@ -0,0 +1,479 @@
|
||||
import { test, expect, type Page } from '@playwright/test';
|
||||
import { execSync } from 'child_process';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
const baseUrl = process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:4173';
|
||||
const urlMatch = baseUrl.match(/:(\d+)$/);
|
||||
const PORT = urlMatch ? urlMatch[1] : '4173';
|
||||
const ALPHA_URL = `http://ecole-alpha.classeo.local:${PORT}`;
|
||||
|
||||
const ADMIN_EMAIL = 'e2e-parenthw-admin@example.com';
|
||||
const ADMIN_PASSWORD = 'AdminParentHW123';
|
||||
const PARENT_EMAIL = 'e2e-parenthw-parent@example.com';
|
||||
const PARENT_PASSWORD = 'ParentHomework123';
|
||||
const TEACHER_EMAIL = 'e2e-parenthw-teacher@example.com';
|
||||
const TEACHER_PASSWORD = 'TeacherParentHW123';
|
||||
const STUDENT1_EMAIL = 'e2e-parenthw-student1@example.com';
|
||||
const STUDENT1_PASSWORD = 'Student1ParentHW123';
|
||||
const STUDENT2_EMAIL = 'e2e-parenthw-student2@example.com';
|
||||
const STUDENT2_PASSWORD = 'Student2ParentHW123';
|
||||
const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||
|
||||
const projectRoot = join(__dirname, '../..');
|
||||
const composeFile = join(projectRoot, 'compose.yaml');
|
||||
|
||||
let student1UserId: string;
|
||||
let student2UserId: string;
|
||||
|
||||
function runSql(sql: string) {
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "${sql}" 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
}
|
||||
|
||||
function clearCache() {
|
||||
try {
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear paginated_queries.cache 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
} catch {
|
||||
// Cache pool may not exist
|
||||
}
|
||||
}
|
||||
|
||||
function resolveDeterministicIds(): { schoolId: string; academicYearId: string } {
|
||||
const output = execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php -r '` +
|
||||
`require "/app/vendor/autoload.php"; ` +
|
||||
`$t="${TENANT_ID}"; $ns="6ba7b814-9dad-11d1-80b4-00c04fd430c8"; ` +
|
||||
`echo Ramsey\\Uuid\\Uuid::uuid5($ns,"school-$t")->toString()."\\n"; ` +
|
||||
`$m=(int)date("n"); $s=$m>=9?(int)date("Y"):(int)date("Y")-1; $e=$s+1; ` +
|
||||
`echo Ramsey\\Uuid\\Uuid::uuid5($ns,"$t:$s-$e")->toString();` +
|
||||
`' 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
).trim();
|
||||
const [schoolId, academicYearId] = output.split('\n');
|
||||
return { schoolId: schoolId!, academicYearId: academicYearId! };
|
||||
}
|
||||
|
||||
function extractUserId(output: string): string {
|
||||
const match = output.match(/User ID\s+([a-f0-9-]{36})/i);
|
||||
if (!match) {
|
||||
throw new Error(`Could not extract User ID from command output:\n${output}`);
|
||||
}
|
||||
return match[1];
|
||||
}
|
||||
|
||||
function getNextWeekday(daysFromNow: number): string {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() + daysFromNow);
|
||||
const day = date.getDay();
|
||||
if (day === 0) date.setDate(date.getDate() + 1);
|
||||
if (day === 6) date.setDate(date.getDate() + 2);
|
||||
const y = date.getFullYear();
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(date.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
function getTomorrowWeekday(): string {
|
||||
return getNextWeekday(1);
|
||||
}
|
||||
|
||||
async function loginAsAdmin(page: Page) {
|
||||
await page.goto(`${ALPHA_URL}/login`);
|
||||
await page.locator('#email').fill(ADMIN_EMAIL);
|
||||
await page.locator('#password').fill(ADMIN_PASSWORD);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
|
||||
page.getByRole('button', { name: /se connecter/i }).click()
|
||||
]);
|
||||
}
|
||||
|
||||
async function loginAsParent(page: Page) {
|
||||
await page.goto(`${ALPHA_URL}/login`);
|
||||
await page.locator('#email').fill(PARENT_EMAIL);
|
||||
await page.locator('#password').fill(PARENT_PASSWORD);
|
||||
await Promise.all([
|
||||
page.waitForURL(/\/dashboard/, { timeout: 30000 }),
|
||||
page.getByRole('button', { name: /se connecter/i }).click()
|
||||
]);
|
||||
}
|
||||
|
||||
async function addGuardianIfNotLinked(page: Page, studentId: string, parentSearchTerm: string, relationship: string) {
|
||||
await page.goto(`${ALPHA_URL}/admin/students/${studentId}`);
|
||||
await expect(page.locator('.guardian-section')).toBeVisible({ timeout: 10000 });
|
||||
await expect(
|
||||
page.getByText(/aucun parent\/tuteur/i).or(page.locator('.guardian-list'))
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const addButton = page.getByRole('button', { name: /ajouter un parent/i });
|
||||
if (!(await addButton.isVisible())) return;
|
||||
|
||||
const sectionText = await page.locator('.guardian-section').textContent();
|
||||
if (sectionText && sectionText.includes(parentSearchTerm)) return;
|
||||
|
||||
await addButton.click();
|
||||
const dialog = page.getByRole('dialog');
|
||||
await expect(dialog).toBeVisible({ timeout: 5000 });
|
||||
|
||||
const searchInput = dialog.getByRole('combobox', { name: /rechercher/i });
|
||||
await searchInput.fill(parentSearchTerm);
|
||||
|
||||
const listbox = dialog.locator('#parent-search-listbox');
|
||||
await expect(listbox).toBeVisible({ timeout: 10000 });
|
||||
const option = listbox.locator('[role="option"]').first();
|
||||
await option.click();
|
||||
|
||||
await expect(dialog.getByText(/sélectionné/i)).toBeVisible();
|
||||
|
||||
await dialog.getByLabel(/type de relation/i).selectOption(relationship);
|
||||
await dialog.getByRole('button', { name: 'Ajouter' }).click();
|
||||
|
||||
await expect(
|
||||
page.locator('.alert-success').or(page.locator('.alert-error'))
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
}
|
||||
|
||||
test.describe('Parent Homework Consultation (Story 5.8)', () => {
|
||||
test.describe.configure({ mode: 'serial', timeout: 60000 });
|
||||
|
||||
const urgentDueDate = getTomorrowWeekday();
|
||||
const futureDueDate = getNextWeekday(10);
|
||||
|
||||
test.beforeAll(async ({ browser }, testInfo) => {
|
||||
testInfo.setTimeout(120000);
|
||||
try {
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console cache:pool:clear cache.rate_limiter users.cache --env=dev 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
} catch {
|
||||
// Cache pools may not exist
|
||||
}
|
||||
|
||||
// Create users
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${ADMIN_EMAIL} --password=${ADMIN_PASSWORD} --role=ROLE_ADMIN 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${PARENT_EMAIL} --password=${PARENT_PASSWORD} --role=ROLE_PARENT --firstName=ParentHW --lastName=TestUser 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${TEACHER_EMAIL} --password=${TEACHER_PASSWORD} --role=ROLE_PROF 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
|
||||
const student1Output = execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${STUDENT1_EMAIL} --password=${STUDENT1_PASSWORD} --role=ROLE_ELEVE --firstName=Emma --lastName=ParentHWTest 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
student1UserId = extractUserId(student1Output);
|
||||
|
||||
const student2Output = execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=${STUDENT2_EMAIL} --password=${STUDENT2_PASSWORD} --role=ROLE_ELEVE --firstName=Lucas --lastName=ParentHWTest 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
student2UserId = extractUserId(student2Output);
|
||||
|
||||
const { schoolId, academicYearId } = resolveDeterministicIds();
|
||||
|
||||
// Ensure classes exist
|
||||
try {
|
||||
runSql(
|
||||
`INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) ` +
|
||||
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E-PHW-6A', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
|
||||
);
|
||||
runSql(
|
||||
`INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) ` +
|
||||
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E-PHW-6B', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
|
||||
);
|
||||
} catch {
|
||||
// May already exist
|
||||
}
|
||||
|
||||
// Ensure subjects exist
|
||||
try {
|
||||
runSql(
|
||||
`INSERT INTO subjects (id, tenant_id, school_id, name, code, color, status, created_at, updated_at) ` +
|
||||
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-PHW-Maths', 'E2EPHWMAT', '#3b82f6', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
|
||||
);
|
||||
runSql(
|
||||
`INSERT INTO subjects (id, tenant_id, school_id, name, code, color, status, created_at, updated_at) ` +
|
||||
`VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-PHW-Français', 'E2EPHWFRA', '#ef4444', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
|
||||
);
|
||||
} catch {
|
||||
// May already exist
|
||||
}
|
||||
|
||||
// Assign students to classes
|
||||
runSql(
|
||||
`INSERT INTO class_assignments (id, tenant_id, user_id, school_class_id, academic_year_id, assigned_at, created_at, updated_at) ` +
|
||||
`SELECT gen_random_uuid(), '${TENANT_ID}', u.id, c.id, '${academicYearId}', NOW(), NOW(), NOW() ` +
|
||||
`FROM users u, school_classes c ` +
|
||||
`WHERE u.email = '${STUDENT1_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
|
||||
`AND c.name = 'E2E-PHW-6A' AND c.tenant_id = '${TENANT_ID}' ` +
|
||||
`ON CONFLICT DO NOTHING`
|
||||
);
|
||||
|
||||
runSql(
|
||||
`INSERT INTO class_assignments (id, tenant_id, user_id, school_class_id, academic_year_id, assigned_at, created_at, updated_at) ` +
|
||||
`SELECT gen_random_uuid(), '${TENANT_ID}', u.id, c.id, '${academicYearId}', NOW(), NOW(), NOW() ` +
|
||||
`FROM users u, school_classes c ` +
|
||||
`WHERE u.email = '${STUDENT2_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
|
||||
`AND c.name = 'E2E-PHW-6B' AND c.tenant_id = '${TENANT_ID}' ` +
|
||||
`ON CONFLICT DO NOTHING`
|
||||
);
|
||||
|
||||
// Clean up stale homework from previous runs
|
||||
try {
|
||||
runSql(
|
||||
`DELETE FROM homework WHERE tenant_id = '${TENANT_ID}' AND class_id IN ` +
|
||||
`(SELECT id FROM school_classes WHERE name IN ('E2E-PHW-6A', 'E2E-PHW-6B') AND tenant_id = '${TENANT_ID}')`
|
||||
);
|
||||
} catch {
|
||||
// Table may not exist
|
||||
}
|
||||
|
||||
// Seed homework for both classes
|
||||
// Urgent homework (due tomorrow) for class 6A
|
||||
runSql(
|
||||
`INSERT INTO homework (id, tenant_id, class_id, subject_id, teacher_id, title, description, due_date, status, created_at, updated_at) ` +
|
||||
`SELECT gen_random_uuid(), '${TENANT_ID}', c.id, s.id, t.id, 'Devoir urgent maths', 'Exercices urgents', '${urgentDueDate}', 'published', NOW(), NOW() ` +
|
||||
`FROM school_classes c, ` +
|
||||
`(SELECT id FROM subjects WHERE code = 'E2EPHWMAT' AND tenant_id = '${TENANT_ID}' LIMIT 1) s, ` +
|
||||
`(SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}') t ` +
|
||||
`WHERE c.name = 'E2E-PHW-6A' AND c.tenant_id = '${TENANT_ID}'`
|
||||
);
|
||||
|
||||
// Future homework for class 6A
|
||||
runSql(
|
||||
`INSERT INTO homework (id, tenant_id, class_id, subject_id, teacher_id, title, description, due_date, status, created_at, updated_at) ` +
|
||||
`SELECT gen_random_uuid(), '${TENANT_ID}', c.id, s.id, t.id, 'Rédaction français Emma', 'Écrire une rédaction', '${futureDueDate}', 'published', NOW(), NOW() ` +
|
||||
`FROM school_classes c, ` +
|
||||
`(SELECT id FROM subjects WHERE code = 'E2EPHWFRA' AND tenant_id = '${TENANT_ID}' LIMIT 1) s, ` +
|
||||
`(SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}') t ` +
|
||||
`WHERE c.name = 'E2E-PHW-6A' AND c.tenant_id = '${TENANT_ID}'`
|
||||
);
|
||||
|
||||
// Homework for class 6B (Lucas)
|
||||
runSql(
|
||||
`INSERT INTO homework (id, tenant_id, class_id, subject_id, teacher_id, title, description, due_date, status, created_at, updated_at) ` +
|
||||
`SELECT gen_random_uuid(), '${TENANT_ID}', c.id, s.id, t.id, 'Exercices maths Lucas', 'Exercices chapitre 7', '${futureDueDate}', 'published', NOW(), NOW() ` +
|
||||
`FROM school_classes c, ` +
|
||||
`(SELECT id FROM subjects WHERE code = 'E2EPHWMAT' AND tenant_id = '${TENANT_ID}' LIMIT 1) s, ` +
|
||||
`(SELECT id FROM users WHERE email = '${TEACHER_EMAIL}' AND tenant_id = '${TENANT_ID}') t ` +
|
||||
`WHERE c.name = 'E2E-PHW-6B' AND c.tenant_id = '${TENANT_ID}'`
|
||||
);
|
||||
|
||||
// Link parent to both students via admin UI
|
||||
const page = await browser.newPage();
|
||||
await loginAsAdmin(page);
|
||||
await addGuardianIfNotLinked(page, student1UserId, PARENT_EMAIL, 'tuteur');
|
||||
await addGuardianIfNotLinked(page, student2UserId, PARENT_EMAIL, 'tutrice');
|
||||
await page.close();
|
||||
|
||||
clearCache();
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// AC1: Liste devoirs enfant
|
||||
// ======================================================================
|
||||
test.describe('AC1: Homework List', () => {
|
||||
test('parent can navigate to homework page via navigation', async ({ page }) => {
|
||||
await loginAsParent(page);
|
||||
|
||||
const nav = page.locator('.desktop-nav');
|
||||
await expect(nav.getByRole('link', { name: /devoirs/i })).toBeVisible({ timeout: 15000 });
|
||||
});
|
||||
|
||||
test('parent homework page shows homework list', async ({ page }) => {
|
||||
await loginAsParent(page);
|
||||
await page.goto(`${ALPHA_URL}/dashboard/parent-homework`);
|
||||
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /devoirs des enfants/i })
|
||||
).toBeVisible({ timeout: 15000 });
|
||||
|
||||
// Homework cards should be visible
|
||||
const cards = page.locator('.homework-card');
|
||||
await expect(cards.first()).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// AC2: Vue identique élève (sans marquage "Fait")
|
||||
// ======================================================================
|
||||
test.describe('AC2: Student-like View Without Done Toggle', () => {
|
||||
test('homework cards do NOT show done toggle checkbox', async ({ page }) => {
|
||||
await loginAsParent(page);
|
||||
await page.goto(`${ALPHA_URL}/dashboard/parent-homework`);
|
||||
|
||||
const card = page.locator('.homework-card').first();
|
||||
await expect(card).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// No toggle-done button should exist (privacy)
|
||||
await expect(card.locator('.toggle-done')).toHaveCount(0);
|
||||
});
|
||||
|
||||
test('homework cards show title and due date', async ({ page }) => {
|
||||
await loginAsParent(page);
|
||||
await page.goto(`${ALPHA_URL}/dashboard/parent-homework`);
|
||||
|
||||
const card = page.locator('.homework-card').first();
|
||||
await expect(card).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Title visible
|
||||
await expect(card.locator('.card-title')).toBeVisible();
|
||||
// Due date visible
|
||||
await expect(card.locator('.due-date')).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// AC3: Vue multi-enfants
|
||||
// ======================================================================
|
||||
test.describe('AC3: Multi-Child View', () => {
|
||||
test('parent with multiple children sees child selector', async ({ page }) => {
|
||||
await loginAsParent(page);
|
||||
await page.goto(`${ALPHA_URL}/dashboard/parent-homework`);
|
||||
|
||||
const childSelector = page.locator('.child-selector');
|
||||
await expect(childSelector).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Should have "Tous" + 2 children buttons
|
||||
const buttons = childSelector.locator('.child-button');
|
||||
await expect(buttons).toHaveCount(3);
|
||||
});
|
||||
|
||||
test('consolidated view shows homework grouped by child', async ({ page }) => {
|
||||
await loginAsParent(page);
|
||||
await page.goto(`${ALPHA_URL}/dashboard/parent-homework`);
|
||||
|
||||
// Wait for data to load
|
||||
const card = page.locator('[data-testid="homework-card"]').first();
|
||||
await expect(card).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Both children's names should appear as section headers
|
||||
const childNames = page.locator('[data-testid="child-name"]');
|
||||
await expect(childNames).toHaveCount(2, { timeout: 10000 });
|
||||
});
|
||||
|
||||
test('clicking a specific child filters to their homework', async ({ page }) => {
|
||||
await loginAsParent(page);
|
||||
await page.goto(`${ALPHA_URL}/dashboard/parent-homework`);
|
||||
|
||||
const childSelector = page.locator('.child-selector');
|
||||
await expect(childSelector).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Click on first child (Emma)
|
||||
const buttons = childSelector.locator('.child-button');
|
||||
await buttons.nth(1).click();
|
||||
|
||||
// Wait for data to reload
|
||||
const card = page.locator('.homework-card').first();
|
||||
await expect(card).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Should no longer show multiple child sections
|
||||
await expect(page.locator('.child-name')).toHaveCount(0, { timeout: 5000 });
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// AC4: Mise en évidence urgence
|
||||
// ======================================================================
|
||||
test.describe('AC4: Urgency Highlight', () => {
|
||||
test('homework due tomorrow shows urgent badge', async ({ page }) => {
|
||||
await loginAsParent(page);
|
||||
await page.goto(`${ALPHA_URL}/dashboard/parent-homework`);
|
||||
|
||||
await expect(page.locator('[data-testid="homework-card"]').first()).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Find urgent badge — text depends on when test runs relative to seeded date
|
||||
const urgentBadge = page.locator('[data-testid="urgent-badge"]');
|
||||
await expect(urgentBadge.first()).toBeVisible({ timeout: 5000 });
|
||||
await expect(urgentBadge.first()).toContainText(/pour demain|aujourd'hui|en retard/i);
|
||||
});
|
||||
|
||||
test('urgent homework card has red styling', async ({ page }) => {
|
||||
await loginAsParent(page);
|
||||
await page.goto(`${ALPHA_URL}/dashboard/parent-homework`);
|
||||
|
||||
await expect(page.locator('[data-testid="homework-card"]').first()).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Urgent card should have the urgent class
|
||||
const urgentCard = page.locator('[data-testid="homework-card"].urgent');
|
||||
await expect(urgentCard.first()).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
|
||||
test('urgent homework shows contact teacher link', async ({ page }) => {
|
||||
await loginAsParent(page);
|
||||
await page.goto(`${ALPHA_URL}/dashboard/parent-homework`);
|
||||
|
||||
await expect(page.locator('[data-testid="homework-card"]').first()).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Contact teacher link should be visible on urgent homework
|
||||
const contactLink = page.locator('[data-testid="contact-teacher"]');
|
||||
await expect(contactLink.first()).toBeVisible({ timeout: 5000 });
|
||||
await expect(contactLink.first()).toContainText(/contacter l'enseignant/i);
|
||||
});
|
||||
|
||||
test('contact teacher link points to messaging page', async ({ page }) => {
|
||||
await loginAsParent(page);
|
||||
await page.goto(`${ALPHA_URL}/dashboard/parent-homework`);
|
||||
|
||||
await expect(page.locator('[data-testid="homework-card"]').first()).toBeVisible({ timeout: 10000 });
|
||||
|
||||
const contactLink = page.locator('[data-testid="contact-teacher"]').first();
|
||||
await expect(contactLink).toBeVisible({ timeout: 5000 });
|
||||
|
||||
// Verify href contains message creation path with proper encoding
|
||||
const href = await contactLink.getAttribute('href');
|
||||
expect(href).toContain('/messages/new');
|
||||
expect(href).toContain('to=');
|
||||
expect(href).toContain('subject=Devoir');
|
||||
});
|
||||
});
|
||||
|
||||
// ======================================================================
|
||||
// Homework detail
|
||||
// ======================================================================
|
||||
test.describe('Homework Detail', () => {
|
||||
test('clicking a homework card shows detail view', async ({ page }) => {
|
||||
await loginAsParent(page);
|
||||
await page.goto(`${ALPHA_URL}/dashboard/parent-homework`);
|
||||
|
||||
const card = page.locator('.homework-card').first();
|
||||
await expect(card).toBeVisible({ timeout: 10000 });
|
||||
await card.click();
|
||||
|
||||
await expect(page.locator('.homework-detail')).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.locator('.detail-title')).toBeVisible();
|
||||
});
|
||||
|
||||
test('back button returns to homework list', async ({ page }) => {
|
||||
await loginAsParent(page);
|
||||
await page.goto(`${ALPHA_URL}/dashboard/parent-homework`);
|
||||
|
||||
const card = page.locator('.homework-card').first();
|
||||
await expect(card).toBeVisible({ timeout: 10000 });
|
||||
await card.click();
|
||||
|
||||
await expect(page.locator('.homework-detail')).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Click back
|
||||
await page.locator('.back-button').click();
|
||||
await expect(page.locator('.homework-card').first()).toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,9 @@
|
||||
<script lang="ts">
|
||||
import type { DemoData } from '$types';
|
||||
import type { ScheduleSlot } from '$lib/features/schedule/api/schedule';
|
||||
import type { StudentHomework } from '$lib/features/homework/api/studentHomework';
|
||||
import { fetchChildDaySchedule } from '$lib/features/schedule/api/parentSchedule';
|
||||
import { fetchChildrenHomework } from '$lib/features/homework/api/parentHomework';
|
||||
import { recordSync } from '$lib/features/schedule/stores/scheduleCache.svelte';
|
||||
import SerenityScorePreview from '$lib/components/molecules/SerenityScore/SerenityScorePreview.svelte';
|
||||
import SerenityScoreExplainer from '$lib/components/molecules/SerenityScore/SerenityScoreExplainer.svelte';
|
||||
@@ -69,13 +71,41 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Load schedule when selectedChildId changes
|
||||
// Homework widget state
|
||||
let parentHomeworks = $state<StudentHomework[]>([]);
|
||||
let homeworkLoading = $state(false);
|
||||
|
||||
async function loadHomeworks() {
|
||||
homeworkLoading = true;
|
||||
|
||||
try {
|
||||
const children = await fetchChildrenHomework();
|
||||
parentHomeworks = children.flatMap((c) => c.homework);
|
||||
} catch {
|
||||
// Silently fail on dashboard widget
|
||||
} finally {
|
||||
homeworkLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatShortDate(dateStr: string): string {
|
||||
const date = new Date(dateStr + 'T00:00:00');
|
||||
return date.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' });
|
||||
}
|
||||
|
||||
// Load schedule and homework when selectedChildId changes
|
||||
$effect(() => {
|
||||
if (isParent && selectedChildId) {
|
||||
loadChildSchedule(selectedChildId);
|
||||
}
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (isParent) {
|
||||
loadHomeworks();
|
||||
}
|
||||
});
|
||||
|
||||
let showExplainer = $state(false);
|
||||
|
||||
const isDemo = $derived(!hasRealData);
|
||||
@@ -181,10 +211,29 @@
|
||||
<!-- Devoirs Section -->
|
||||
<DashboardSection
|
||||
title="Devoirs à venir"
|
||||
isPlaceholder={!hasRealData}
|
||||
isPlaceholder={!isParent && !hasRealData}
|
||||
placeholderMessage="Les devoirs seront affichés ici une fois assignés"
|
||||
>
|
||||
{#if hasRealData}
|
||||
{#if isParent}
|
||||
{#if homeworkLoading}
|
||||
<SkeletonList items={3} message="Chargement des devoirs..." />
|
||||
{:else if parentHomeworks.length === 0}
|
||||
<p class="empty-homework">Aucun devoir à venir</p>
|
||||
{:else}
|
||||
<ul class="homework-list">
|
||||
{#each parentHomeworks.slice(0, 5) as homework}
|
||||
<li class="homework-item" style:border-left-color={homework.subjectColor ?? '#3b82f6'}>
|
||||
<span class="homework-subject" style:color={homework.subjectColor ?? '#3b82f6'}>{homework.subjectName}</span>
|
||||
<span class="homework-title">{homework.title}</span>
|
||||
<span class="homework-due">Pour le {formatShortDate(homework.dueDate)}</span>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<a href="/dashboard/parent-homework" class="view-all-link">
|
||||
Voir tous les devoirs →
|
||||
</a>
|
||||
{/if}
|
||||
{:else if hasRealData}
|
||||
{#if isLoading}
|
||||
<SkeletonList items={3} message="Chargement des devoirs..." />
|
||||
{:else}
|
||||
@@ -416,4 +465,27 @@
|
||||
background: #fee2e2;
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.view-all-link {
|
||||
display: block;
|
||||
text-align: center;
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: #3b82f6;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.view-all-link:hover {
|
||||
color: #2563eb;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.empty-homework {
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
color: #6b7280;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,455 @@
|
||||
<script lang="ts">
|
||||
import type { ChildHomework } from '$lib/features/homework/api/parentHomework';
|
||||
import type { StudentHomework, StudentHomeworkDetail as HomeworkDetailType } from '$lib/features/homework/api/studentHomework';
|
||||
import { fetchChildrenHomework, fetchChildHomework, fetchParentHomeworkDetail, getParentAttachmentUrl } from '$lib/features/homework/api/parentHomework';
|
||||
import { isOffline } from '$lib/utils/network';
|
||||
import { cacheParentHomework, getCachedParentHomework } from '$lib/features/homework/stores/parentHomeworkCache.svelte';
|
||||
import ChildSelector from '$lib/components/organisms/ChildSelector/ChildSelector.svelte';
|
||||
import HomeworkDetail from '$lib/components/organisms/StudentHomework/HomeworkDetail.svelte';
|
||||
import SkeletonList from '$lib/components/atoms/Skeleton/SkeletonList.svelte';
|
||||
|
||||
let childrenData = $state<ChildHomework[]>([]);
|
||||
let selectedChildId = $state<string | null>(null);
|
||||
let loading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let allSubjects = $state<{ id: string; name: string; color: string | null }[]>([]);
|
||||
let selectedSubjectId = $state<string | null>(null);
|
||||
let selectedDetail = $state<HomeworkDetailType | null>(null);
|
||||
let detailLoading = $state(false);
|
||||
|
||||
let allHomework = $derived(
|
||||
childrenData.flatMap((child) =>
|
||||
child.homework.map((hw) => ({ ...hw, childName: `${child.firstName} ${child.lastName}` }))
|
||||
)
|
||||
);
|
||||
|
||||
function extractSubjects(hws: StudentHomework[]): { id: string; name: string; color: string | null }[] {
|
||||
const map = new Map<string, { id: string; name: string; color: string | null }>();
|
||||
for (const hw of hws) {
|
||||
if (!map.has(hw.subjectId)) {
|
||||
map.set(hw.subjectId, { id: hw.subjectId, name: hw.subjectName, color: hw.subjectColor });
|
||||
}
|
||||
}
|
||||
return Array.from(map.values());
|
||||
}
|
||||
|
||||
type UrgencyLevel = 'overdue' | 'today' | 'tomorrow';
|
||||
|
||||
function urgencyLevel(dueDate: string): UrgencyLevel | null {
|
||||
const now = new Date();
|
||||
const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
|
||||
const tom = new Date(now);
|
||||
tom.setDate(tom.getDate() + 1);
|
||||
const tomorrowStr = `${tom.getFullYear()}-${String(tom.getMonth() + 1).padStart(2, '0')}-${String(tom.getDate()).padStart(2, '0')}`;
|
||||
|
||||
if (dueDate < todayStr) return 'overdue';
|
||||
if (dueDate === todayStr) return 'today';
|
||||
if (dueDate === tomorrowStr) return 'tomorrow';
|
||||
return null;
|
||||
}
|
||||
|
||||
function urgencyLabel(level: UrgencyLevel): string {
|
||||
switch (level) {
|
||||
case 'overdue': return 'En retard';
|
||||
case 'today': return "Aujourd'hui";
|
||||
case 'tomorrow': return 'Pour demain';
|
||||
}
|
||||
}
|
||||
|
||||
function formatDueDate(dateStr: string): string {
|
||||
const date = new Date(dateStr + 'T00:00:00');
|
||||
return date.toLocaleDateString('fr-FR', {
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'long'
|
||||
});
|
||||
}
|
||||
|
||||
async function loadHomework() {
|
||||
loading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
if (selectedChildId) {
|
||||
const child = await fetchChildHomework(selectedChildId, selectedSubjectId ?? undefined);
|
||||
childrenData = [child];
|
||||
} else {
|
||||
childrenData = await fetchChildrenHomework(selectedSubjectId ?? undefined);
|
||||
}
|
||||
cacheParentHomework(childrenData);
|
||||
if (selectedSubjectId === null) {
|
||||
const all = childrenData.flatMap((c) => c.homework);
|
||||
allSubjects = extractSubjects(all);
|
||||
}
|
||||
} catch (e) {
|
||||
const cached = getCachedParentHomework();
|
||||
if (cached) {
|
||||
childrenData = cached;
|
||||
if (selectedSubjectId === null) {
|
||||
allSubjects = extractSubjects(cached.flatMap((c) => c.homework));
|
||||
}
|
||||
} else {
|
||||
error = e instanceof Error ? e.message : 'Erreur de chargement';
|
||||
}
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleChildSelected(childId: string | null) {
|
||||
selectedChildId = childId;
|
||||
selectedSubjectId = null;
|
||||
loadHomework();
|
||||
}
|
||||
|
||||
function handleFilterChange(subjectId: string | null) {
|
||||
selectedSubjectId = subjectId;
|
||||
loadHomework();
|
||||
}
|
||||
|
||||
async function handleCardClick(homeworkId: string) {
|
||||
detailLoading = true;
|
||||
|
||||
try {
|
||||
selectedDetail = await fetchParentHomeworkDetail(homeworkId);
|
||||
} catch {
|
||||
// Stay on list if detail fails
|
||||
} finally {
|
||||
detailLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleBack() {
|
||||
selectedDetail = null;
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
loadHomework();
|
||||
});
|
||||
</script>
|
||||
|
||||
{#snippet homeworkCard(hw: StudentHomework)}
|
||||
{@const level = urgencyLevel(hw.dueDate)}
|
||||
<div
|
||||
class="homework-card"
|
||||
class:urgent={level !== null}
|
||||
data-testid="homework-card"
|
||||
style:border-left-color={hw.subjectColor ?? '#3b82f6'}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => handleCardClick(hw.id)}
|
||||
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleCardClick(hw.id); } }}
|
||||
>
|
||||
<div class="card-header">
|
||||
<span class="subject-name" style:color={hw.subjectColor ?? '#3b82f6'}>{hw.subjectName}</span>
|
||||
{#if level}
|
||||
<span class="urgent-badge" class:overdue={level === 'overdue'} data-testid="urgent-badge">
|
||||
{urgencyLabel(level)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<h4 class="card-title">{hw.title}</h4>
|
||||
<div class="card-footer">
|
||||
<span class="due-date">Pour le {formatDueDate(hw.dueDate)}</span>
|
||||
{#if hw.hasAttachments}
|
||||
<span class="attachment-indicator" title="Pièce(s) jointe(s)">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48"/>
|
||||
</svg>
|
||||
</span>
|
||||
{/if}
|
||||
{#if level}
|
||||
<a
|
||||
href="/messages/new?to={hw.teacherId}&subject={encodeURIComponent('Devoir: ' + hw.title)}"
|
||||
class="contact-teacher"
|
||||
data-testid="contact-teacher"
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
Contacter l'enseignant
|
||||
</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
|
||||
{#if selectedDetail}
|
||||
<HomeworkDetail detail={selectedDetail} onBack={handleBack} getAttachmentUrl={getParentAttachmentUrl} />
|
||||
{:else}
|
||||
<div class="parent-homework">
|
||||
<ChildSelector onChildSelected={handleChildSelected} />
|
||||
|
||||
{#if isOffline()}
|
||||
<div class="offline-banner" role="status">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<line x1="1" y1="1" x2="23" y2="23"/>
|
||||
<path d="M16.72 11.06A10.94 10.94 0 0 1 19 12.55"/>
|
||||
<path d="M5 12.55a10.94 10.94 0 0 1 5.17-2.39"/>
|
||||
<path d="M10.71 5.05A16 16 0 0 1 22.56 9"/>
|
||||
<path d="M1.42 9a15.91 15.91 0 0 1 4.7-2.88"/>
|
||||
<path d="M8.53 16.11a6 6 0 0 1 6.95 0"/>
|
||||
<line x1="12" y1="20" x2="12.01" y2="20"/>
|
||||
</svg>
|
||||
Mode hors ligne
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if allSubjects.length > 1}
|
||||
<div class="filter-bar" role="toolbar" aria-label="Filtrer par matière">
|
||||
<button
|
||||
class="filter-chip"
|
||||
class:active={selectedSubjectId === null}
|
||||
onclick={() => handleFilterChange(null)}
|
||||
>
|
||||
Tous
|
||||
</button>
|
||||
{#each allSubjects as subject}
|
||||
<button
|
||||
class="filter-chip"
|
||||
class:active={selectedSubjectId === subject.id}
|
||||
style:--chip-color={subject.color ?? '#3b82f6'}
|
||||
onclick={() => handleFilterChange(subject.id)}
|
||||
>
|
||||
{subject.name}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if loading}
|
||||
<SkeletonList items={4} message="Chargement des devoirs..." />
|
||||
{:else if error}
|
||||
<div class="error-message" role="alert">
|
||||
<p>{error}</p>
|
||||
<button onclick={() => void loadHomework()}>Réessayer</button>
|
||||
</div>
|
||||
{:else if allHomework.length === 0}
|
||||
<div class="empty-state">
|
||||
<p>Aucun devoir pour le moment</p>
|
||||
</div>
|
||||
{:else}
|
||||
{#if childrenData.length > 1}
|
||||
{#each childrenData as child (child.childId)}
|
||||
{#if child.homework.length > 0}
|
||||
<section class="child-section">
|
||||
<h3 class="child-name" data-testid="child-name">{child.firstName} {child.lastName}</h3>
|
||||
<ul class="homework-list" role="list">
|
||||
{#each child.homework as hw (hw.id)}
|
||||
<li>{@render homeworkCard(hw)}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/if}
|
||||
{/each}
|
||||
{:else}
|
||||
<ul class="homework-list" role="list">
|
||||
{#each allHomework as hw (hw.id)}
|
||||
<li>{@render homeworkCard(hw)}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if detailLoading}
|
||||
<div class="detail-loading-overlay" role="status">
|
||||
<p>Chargement...</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.parent-homework {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.offline-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: #fef3c7;
|
||||
border: 1px solid #f59e0b;
|
||||
border-radius: 0.5rem;
|
||||
color: #92400e;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filter-chip {
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 1rem;
|
||||
background: white;
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, border-color 0.15s;
|
||||
}
|
||||
|
||||
.filter-chip:hover {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.filter-chip.active {
|
||||
background: var(--chip-color, #3b82f6);
|
||||
color: white;
|
||||
border-color: var(--chip-color, #3b82f6);
|
||||
}
|
||||
|
||||
.child-section {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.child-name {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.homework-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.homework-card {
|
||||
padding: 0.75rem;
|
||||
background: #f9fafb;
|
||||
border-radius: 0.5rem;
|
||||
border-left: 3px solid #3b82f6;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s, box-shadow 0.15s;
|
||||
}
|
||||
|
||||
.homework-card:hover {
|
||||
background: #f3f4f6;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.homework-card:focus-visible {
|
||||
outline: 2px solid #3b82f6;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.homework-card.urgent {
|
||||
border-left-color: #ef4444;
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.homework-card.urgent:hover {
|
||||
background: #fee2e2;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.subject-name {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.urgent-badge {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 700;
|
||||
color: #991b1b;
|
||||
background: #fecaca;
|
||||
border-radius: 999px;
|
||||
padding: 0.125rem 0.5rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.urgent-badge.overdue {
|
||||
color: #7f1d1d;
|
||||
background: #fca5a5;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.card-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.due-date {
|
||||
font-size: 0.8125rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.attachment-indicator {
|
||||
color: #6b7280;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.contact-teacher {
|
||||
margin-left: auto;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #3b82f6;
|
||||
text-decoration: none;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 0.375rem;
|
||||
background: white;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.contact-teacher:hover {
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.error-message button {
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: 1px solid #ef4444;
|
||||
border-radius: 0.375rem;
|
||||
background: white;
|
||||
color: #ef4444;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.detail-loading-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
z-index: 50;
|
||||
}
|
||||
</style>
|
||||
@@ -1,14 +1,16 @@
|
||||
<script lang="ts">
|
||||
import type { StudentHomeworkDetail } from '$lib/features/homework/api/studentHomework';
|
||||
import { getAttachmentUrl } from '$lib/features/homework/api/studentHomework';
|
||||
import { getAttachmentUrl as defaultGetAttachmentUrl } from '$lib/features/homework/api/studentHomework';
|
||||
import { authenticatedFetch } from '$lib/auth';
|
||||
|
||||
let {
|
||||
detail,
|
||||
onBack
|
||||
onBack,
|
||||
getAttachmentUrl = defaultGetAttachmentUrl
|
||||
}: {
|
||||
detail: StudentHomeworkDetail;
|
||||
onBack: () => void;
|
||||
getAttachmentUrl?: (homeworkId: string, attachmentId: string) => string;
|
||||
} = $props();
|
||||
|
||||
function formatDueDate(dateStr: string): string {
|
||||
|
||||
65
frontend/src/lib/features/homework/api/parentHomework.ts
Normal file
65
frontend/src/lib/features/homework/api/parentHomework.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { getApiBaseUrl } from '$lib/api';
|
||||
import { authenticatedFetch } from '$lib/auth';
|
||||
import type { StudentHomework, StudentHomeworkDetail } from './studentHomework';
|
||||
|
||||
export interface ChildHomework {
|
||||
childId: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
homework: StudentHomework[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les devoirs de tous les enfants du parent connecté.
|
||||
*/
|
||||
export async function fetchChildrenHomework(subjectId?: string): Promise<ChildHomework[]> {
|
||||
const apiUrl = getApiBaseUrl();
|
||||
const params = subjectId ? `?subjectId=${encodeURIComponent(subjectId)}` : '';
|
||||
const response = await authenticatedFetch(`${apiUrl}/me/children/homework${params}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Erreur lors du chargement des devoirs (${response.status})`);
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
return json.data ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les devoirs d'un enfant spécifique.
|
||||
*/
|
||||
export async function fetchChildHomework(childId: string, subjectId?: string): Promise<ChildHomework> {
|
||||
const apiUrl = getApiBaseUrl();
|
||||
const params = subjectId ? `?subjectId=${encodeURIComponent(subjectId)}` : '';
|
||||
const response = await authenticatedFetch(`${apiUrl}/me/children/${childId}/homework${params}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Erreur lors du chargement des devoirs (${response.status})`);
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
return json.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère le détail d'un devoir via l'endpoint parent.
|
||||
*/
|
||||
/**
|
||||
* Retourne l'URL de téléchargement d'une pièce jointe (endpoint parent).
|
||||
*/
|
||||
export function getParentAttachmentUrl(homeworkId: string, attachmentId: string): string {
|
||||
const apiUrl = getApiBaseUrl();
|
||||
return `${apiUrl}/me/children/homework/${homeworkId}/attachments/${attachmentId}`;
|
||||
}
|
||||
|
||||
export async function fetchParentHomeworkDetail(homeworkId: string): Promise<StudentHomeworkDetail> {
|
||||
const apiUrl = getApiBaseUrl();
|
||||
const response = await authenticatedFetch(`${apiUrl}/me/children/homework/${homeworkId}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Erreur lors du chargement du devoir (${response.status})`);
|
||||
}
|
||||
|
||||
const json = await response.json();
|
||||
return json.data;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { browser } from '$app/environment';
|
||||
import { getCurrentUserId } from '$lib/auth/auth.svelte';
|
||||
import type { ChildHomework } from '$lib/features/homework/api/parentHomework';
|
||||
|
||||
const CACHE_PREFIX = 'classeo:parent-homework:cache:';
|
||||
const MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24h
|
||||
|
||||
interface CacheEntry {
|
||||
data: ChildHomework[];
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
function cacheKey(): string | null {
|
||||
const userId = getCurrentUserId();
|
||||
return userId ? `${CACHE_PREFIX}${userId}` : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sauvegarde les devoirs en cache localStorage, scopé par utilisateur.
|
||||
*/
|
||||
export function cacheParentHomework(data: ChildHomework[]): void {
|
||||
if (!browser) return;
|
||||
|
||||
const key = cacheKey();
|
||||
if (!key) return;
|
||||
|
||||
const entry: CacheEntry = {
|
||||
data,
|
||||
timestamp: Date.now(),
|
||||
};
|
||||
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(entry));
|
||||
} catch {
|
||||
// localStorage full — ignore silently
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Récupère les devoirs depuis le cache si encore valides.
|
||||
*/
|
||||
export function getCachedParentHomework(): ChildHomework[] | null {
|
||||
if (!browser) return null;
|
||||
|
||||
const key = cacheKey();
|
||||
if (!key) return null;
|
||||
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (!raw) return null;
|
||||
|
||||
const entry: CacheEntry = JSON.parse(raw);
|
||||
|
||||
if (Date.now() - entry.timestamp > MAX_AGE_MS) {
|
||||
localStorage.removeItem(key);
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry.data;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,13 @@
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
export { isOffline } from '$lib/utils/network';
|
||||
|
||||
const LAST_SYNC_KEY = 'classeo:schedule:lastSync';
|
||||
|
||||
let lastSyncValue = $state<string | null>(
|
||||
browser ? localStorage.getItem(LAST_SYNC_KEY) : null
|
||||
);
|
||||
|
||||
/**
|
||||
* Vérifie si le navigateur est actuellement hors ligne.
|
||||
*/
|
||||
export function isOffline(): boolean {
|
||||
if (!browser) return false;
|
||||
return !navigator.onLine;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enregistre la date de dernière synchronisation de l'EDT.
|
||||
*/
|
||||
|
||||
9
frontend/src/lib/utils/network.ts
Normal file
9
frontend/src/lib/utils/network.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { browser } from '$app/environment';
|
||||
|
||||
/**
|
||||
* Vérifie si le navigateur est actuellement hors ligne.
|
||||
*/
|
||||
export function isOffline(): boolean {
|
||||
if (!browser) return false;
|
||||
return !navigator.onLine;
|
||||
}
|
||||
@@ -111,6 +111,7 @@
|
||||
{/if}
|
||||
{#if isParent}
|
||||
<a href="/dashboard/parent-schedule" class="nav-link" class:active={pathname === '/dashboard/parent-schedule'}>EDT enfants</a>
|
||||
<a href="/dashboard/parent-homework" class="nav-link" class:active={pathname === '/dashboard/parent-homework'}>Devoirs</a>
|
||||
{/if}
|
||||
<button class="nav-button" onclick={goSettings}>Paramètres</button>
|
||||
<button class="logout-button" onclick={handleLogout} disabled={isLoggingOut}>
|
||||
@@ -163,6 +164,9 @@
|
||||
<a href="/dashboard/parent-schedule" class="mobile-nav-link" class:active={pathname === '/dashboard/parent-schedule'}>
|
||||
EDT enfants
|
||||
</a>
|
||||
<a href="/dashboard/parent-homework" class="mobile-nav-link" class:active={pathname === '/dashboard/parent-homework'}>
|
||||
Devoirs
|
||||
</a>
|
||||
{/if}
|
||||
<button class="mobile-nav-link" onclick={goSettings}>Paramètres</button>
|
||||
</div>
|
||||
|
||||
29
frontend/src/routes/dashboard/parent-homework/+page.svelte
Normal file
29
frontend/src/routes/dashboard/parent-homework/+page.svelte
Normal file
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import ParentHomeworkView from '$lib/components/organisms/ParentHomework/ParentHomeworkView.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Devoirs des enfants - Classeo</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="homework-page">
|
||||
<header class="page-header">
|
||||
<h1>Devoirs des enfants</h1>
|
||||
</header>
|
||||
<ParentHomeworkView />
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.homework-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: #1f2937;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user