fix: Filtrer enseignants et matières par affectation lors de la création de créneaux

Quand une classe n'avait aucune affectation enseignant-matière, les
selects de la modale de création de créneau affichaient tous les
enseignants et toutes les matières au lieu d'une liste vide. Cela
permettait de soumettre des combinaisons invalides, produisant un
message d'erreur avec des UUID incompréhensibles.

Les dropdowns n'affichent plus que les enseignants/matières effectivement
affectés à la classe sélectionnée. Le message d'erreur backend est
reformulé sans UUID pour le cas où la validation frontend serait
contournée.
This commit is contained in:
2026-03-06 12:07:28 +01:00
parent ba80e8cb57
commit 39f8650b92
6 changed files with 211 additions and 87 deletions

View File

@@ -42,10 +42,12 @@ 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"; ` +
`$t="${TENANT_ID}"; ` +
`$dns="6ba7b810-9dad-11d1-80b4-00c04fd430c8"; ` +
`$ay="6ba7b814-9dad-11d1-80b4-00c04fd430c8"; ` +
`echo Ramsey\\Uuid\\Uuid::uuid5($dns,"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();` +
`echo Ramsey\\Uuid\\Uuid::uuid5($ay,"$t:$s-$e")->toString();` +
`' 2>&1`,
{ encoding: 'utf-8' }
).trim();
@@ -147,11 +149,13 @@ async function fillSlotForm(
if (className) {
await dialog.locator('#slot-class').selectOption({ label: className });
}
// Wait for assignments to load — only the test teacher is assigned,
// so the teacher dropdown filters down to 1 option
// Wait for assignments to load, then select subject first (filters teachers)
const subjectOptions = dialog.locator('#slot-subject option:not([value=""])');
await expect(subjectOptions.first()).toBeAttached({ timeout: 10000 });
await dialog.locator('#slot-subject').selectOption({ index: 1 });
// After subject selection, wait for teacher dropdown to be filtered
const teacherOptions = dialog.locator('#slot-teacher option:not([value=""])');
await expect(teacherOptions).toHaveCount(1, { timeout: 10000 });
await dialog.locator('#slot-subject').selectOption({ index: 1 });
await dialog.locator('#slot-teacher').selectOption({ index: 1 });
await dialog.locator('#slot-day').selectOption(dayValue);
await dialog.locator('#slot-start').fill(startTime);
@@ -180,39 +184,31 @@ test.describe('Schedule Management - Modification & Conflicts & Calendar (Story
const { schoolId, academicYearId } = resolveDeterministicIds();
// Ensure test classes exist
// Clean up stale test data (e.g. from previous runs with wrong school_id)
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-Schedule-6A', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
runSql(`DELETE FROM schedule_slots WHERE class_id IN (SELECT id FROM school_classes WHERE name IN ('E2E-Schedule-6A','E2E-Schedule-5A') AND tenant_id = '${TENANT_ID}')`);
runSql(`DELETE FROM teacher_assignments WHERE school_class_id IN (SELECT id FROM school_classes WHERE name IN ('E2E-Schedule-6A','E2E-Schedule-5A') AND tenant_id = '${TENANT_ID}')`);
runSql(`DELETE FROM school_classes WHERE name IN ('E2E-Schedule-6A','E2E-Schedule-5A') AND tenant_id = '${TENANT_ID}'`);
runSql(`DELETE FROM subjects WHERE code IN ('E2SMATH','E2SFRA') AND tenant_id = '${TENANT_ID}'`);
} catch {
// May already exist
// Tables may not 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-Schedule-5A', '5ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
} catch {
// May already exist
}
// Create test classes
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-Schedule-6A', '6ème', 'active', NOW(), NOW())`
);
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-Schedule-5A', '5ème', 'active', NOW(), NOW())`
);
// Ensure test 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-Schedule-Maths', 'E2ESCHEDMATH', '#3b82f6', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
} catch {
// May already 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-Schedule-Français', 'E2ESCHEDFRA', '#ef4444', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
} catch {
// May already exist
}
// Create test subjects
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-Schedule-Maths', 'E2SMATH', '#3b82f6', 'active', NOW(), NOW())`
);
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-Schedule-Français', 'E2SFRA', '#ef4444', 'active', NOW(), NOW())`
);
cleanupScheduleData();
cleanupCalendarEntries();

View File

@@ -42,10 +42,12 @@ 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"; ` +
`$t="${TENANT_ID}"; ` +
`$dns="6ba7b810-9dad-11d1-80b4-00c04fd430c8"; ` +
`$ay="6ba7b814-9dad-11d1-80b4-00c04fd430c8"; ` +
`echo Ramsey\\Uuid\\Uuid::uuid5($dns,"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();` +
`echo Ramsey\\Uuid\\Uuid::uuid5($ay,"$t:$s-$e")->toString();` +
`' 2>&1`,
{ encoding: 'utf-8' }
).trim();
@@ -114,11 +116,13 @@ async function fillSlotForm(
if (className) {
await dialog.locator('#slot-class').selectOption({ label: className });
}
// Wait for assignments to load — only the test teacher is assigned,
// so the teacher dropdown filters down to 1 option
// Wait for assignments to load, then select subject first (filters teachers)
const subjectOptions = dialog.locator('#slot-subject option:not([value=""])');
await expect(subjectOptions.first()).toBeAttached({ timeout: 10000 });
await dialog.locator('#slot-subject').selectOption({ index: 1 });
// After subject selection, wait for teacher dropdown to be filtered
const teacherOptions = dialog.locator('#slot-teacher option:not([value=""])');
await expect(teacherOptions).toHaveCount(1, { timeout: 10000 });
await dialog.locator('#slot-subject').selectOption({ index: 1 });
await dialog.locator('#slot-teacher').selectOption({ index: 1 });
await dialog.locator('#slot-day').selectOption(dayValue);
await dialog.locator('#slot-start').fill(startTime);
@@ -147,40 +151,31 @@ test.describe('Schedule Management - Navigation & Grid & Creation (Story 4.1)',
const { schoolId, academicYearId } = resolveDeterministicIds();
// Ensure test class exists
// Clean up stale test data (e.g. from previous runs with wrong school_id)
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-Schedule-6A', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
runSql(`DELETE FROM schedule_slots WHERE class_id IN (SELECT id FROM school_classes WHERE name IN ('E2E-Schedule-6A','E2E-Schedule-5A') AND tenant_id = '${TENANT_ID}')`);
runSql(`DELETE FROM teacher_assignments WHERE school_class_id IN (SELECT id FROM school_classes WHERE name IN ('E2E-Schedule-6A','E2E-Schedule-5A') AND tenant_id = '${TENANT_ID}')`);
runSql(`DELETE FROM school_classes WHERE name IN ('E2E-Schedule-6A','E2E-Schedule-5A') AND tenant_id = '${TENANT_ID}'`);
runSql(`DELETE FROM subjects WHERE code IN ('E2SMATH','E2SFRA') AND tenant_id = '${TENANT_ID}'`);
} catch {
// May already exist
// Tables may not exist
}
// Ensure second test class exists (for conflict tests across classes)
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-Schedule-5A', '5ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
} catch {
// May already exist
}
// Create test classes
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-Schedule-6A', '6ème', 'active', NOW(), NOW())`
);
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-Schedule-5A', '5ème', 'active', NOW(), NOW())`
);
// Ensure test 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-Schedule-Maths', 'E2ESCHEDMATH', '#3b82f6', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
} catch {
// May already 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-Schedule-Français', 'E2ESCHEDFRA', '#ef4444', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
);
} catch {
// May already exist
}
// Create test subjects
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-Schedule-Maths', 'E2SMATH', '#3b82f6', 'active', NOW(), NOW())`
);
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-Schedule-Français', 'E2SFRA', '#ef4444', 'active', NOW(), NOW())`
);
cleanupScheduleData();
clearCache();
@@ -375,6 +370,78 @@ test.describe('Schedule Management - Navigation & Grid & Creation (Story 4.1)',
await expect(page.locator('.slot-card').getByText('A101')).toBeVisible();
});
test('subject field appears before teacher field in creation form', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/schedule`);
await waitForScheduleReady(page);
const timeCell = page.locator('.time-cell').first();
await timeCell.click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });
// Subject should appear before teacher in DOM order
const formGroups = dialog.locator('.form-group');
const labels = await formGroups.locator('label').allTextContents();
const subjectIndex = labels.findIndex((l) => l.includes('Matière'));
const teacherIndex = labels.findIndex((l) => l.includes('Enseignant'));
expect(subjectIndex).toBeLessThan(teacherIndex);
});
test('selecting a subject filters the teacher dropdown', async ({ page }) => {
const { academicYearId } = resolveDeterministicIds();
// Create a second teacher
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console app:dev:create-test-user --tenant=ecole-alpha --email=e2e-sched-teacher2@example.com --password=Teacher2Pass123 --role=ROLE_PROF 2>&1`,
{ encoding: 'utf-8' }
);
// Assign teacher1 to subject1, teacher2 to subject2 for class 6A
runSql(`DELETE FROM teacher_assignments WHERE tenant_id = '${TENANT_ID}'`);
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, school_classes c, (SELECT id FROM subjects WHERE code = 'E2SMATH' AND tenant_id = '${TENANT_ID}') s ` +
`WHERE u.email = '${TEACHER_EMAIL}' AND u.tenant_id = '${TENANT_ID}' ` +
`AND c.name = 'E2E-Schedule-6A' AND c.tenant_id = '${TENANT_ID}' ` +
`ON CONFLICT DO NOTHING`
);
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, school_classes c, (SELECT id FROM subjects WHERE code = 'E2SFRA' AND tenant_id = '${TENANT_ID}') s ` +
`WHERE u.email = 'e2e-sched-teacher2@example.com' AND u.tenant_id = '${TENANT_ID}' ` +
`AND c.name = 'E2E-Schedule-6A' AND c.tenant_id = '${TENANT_ID}' ` +
`ON CONFLICT DO NOTHING`
);
clearCache();
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/schedule`);
await waitForScheduleReady(page);
const timeCell = page.locator('.time-cell').first();
await timeCell.click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible({ timeout: 10000 });
// Select class 6A — both subjects should be available
await dialog.locator('#slot-class').selectOption({ label: 'E2E-Schedule-6A' });
const subjectOptions = dialog.locator('#slot-subject option:not([value=""])');
await expect(subjectOptions).toHaveCount(2, { timeout: 15000 });
// Before selecting a subject, both teachers should be available
const teacherOptions = dialog.locator('#slot-teacher option:not([value=""])');
await expect(teacherOptions).toHaveCount(2, { timeout: 10000 });
// Select first subject (Français) — should filter to only teacher2
await dialog.locator('#slot-subject').selectOption({ index: 1 });
await expect(teacherOptions).toHaveCount(1, { timeout: 10000 });
});
test('filters subjects and teachers by class assignment', async ({ page }) => {
const { academicYearId } = resolveDeterministicIds();
@@ -434,9 +501,10 @@ test.describe('Schedule Recurring - Week Navigation & Scope (Story 4.2)', () =>
const { schoolId, academicYearId } = resolveDeterministicIds();
// Only insert if not already created by first describe block
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-Schedule-6A', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
`INSERT INTO school_classes (id, tenant_id, school_id, academic_year_id, name, level, status, created_at, updated_at) SELECT gen_random_uuid(), '${TENANT_ID}', '${schoolId}', '${academicYearId}', 'E2E-Schedule-6A', '6ème', 'active', NOW(), NOW() WHERE NOT EXISTS (SELECT 1 FROM school_classes WHERE name = 'E2E-Schedule-6A' AND tenant_id = '${TENANT_ID}')`
);
} catch {
// May already exist
@@ -444,7 +512,7 @@ test.describe('Schedule Recurring - Week Navigation & Scope (Story 4.2)', () =>
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-Schedule-Maths', 'E2ESCHEDMATH', '#3b82f6', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
`INSERT INTO subjects (id, tenant_id, school_id, name, code, color, status, created_at, updated_at) SELECT gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-Schedule-Maths', 'E2SMATH', '#3b82f6', 'active', NOW(), NOW() WHERE NOT EXISTS (SELECT 1 FROM subjects WHERE code = 'E2SMATH' AND tenant_id = '${TENANT_ID}')`
);
} catch {
// May already exist

View File

@@ -142,7 +142,6 @@
if (!classId) return subjects;
const filtered = assignments.filter((a) => a.classId === classId);
const subjectIds = new Set(filtered.map((a) => a.subjectId));
if (subjectIds.size === 0) return subjects;
return subjects.filter((s) => subjectIds.has(s.id));
});
@@ -154,7 +153,6 @@
? assignments.filter((a) => a.classId === classId && a.subjectId === formSubjectId)
: assignments.filter((a) => a.classId === classId);
const teacherIds = new Set(filtered.map((a) => a.teacherId));
if (teacherIds.size === 0) return teachers;
return teachers.filter((t) => teacherIds.has(t.id));
});
@@ -915,16 +913,6 @@
</select>
</div>
<div class="form-group">
<label for="slot-teacher">Enseignant *</label>
<select id="slot-teacher" bind:value={formTeacherId} required>
<option value="">-- Sélectionner --</option>
{#each availableTeachers as teacher (teacher.id)}
<option value={teacher.id}>{teacher.firstName} {teacher.lastName}</option>
{/each}
</select>
</div>
<div class="form-group">
<label for="slot-subject">Matière *</label>
<select id="slot-subject" bind:value={formSubjectId} required>
@@ -935,6 +923,16 @@
</select>
</div>
<div class="form-group">
<label for="slot-teacher">Enseignant *</label>
<select id="slot-teacher" bind:value={formTeacherId} required>
<option value="">-- Sélectionner --</option>
{#each availableTeachers as teacher (teacher.id)}
<option value={teacher.id}>{teacher.firstName} {teacher.lastName}</option>
{/each}
</select>
</div>
<div class="form-group">
<label for="slot-day">Jour *</label>
<select id="slot-day" bind:value={formDayOfWeek} required>