feat: Désignation de remplaçants temporaires avec corrections sécurité
Permet aux administrateurs de désigner un enseignant remplaçant pour un autre enseignant absent, sur des classes et matières précises, pour une période donnée. Le dashboard enseignant affiche les remplacements actifs avec les noms de classes/matières au lieu des identifiants bruts. Inclut les corrections de la code review : - Requête findActiveByTenant qui excluait les remplacements en cours mais incluait les futurs (manquait start_date <= :at) - Validation tenant et rôle enseignant dans le handler de désignation pour empêcher l'affectation cross-tenant ou de non-enseignants - Validation structurée du payload classes (Assert\Collection + UUID) pour éviter les erreurs serveur sur payloads malformés - API replaced-classes enrichie avec les noms classe/matière
This commit is contained in:
222
frontend/e2e/admin-responsive-nav.spec.ts
Normal file
222
frontend/e2e/admin-responsive-nav.spec.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
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 ADMIN_EMAIL = 'e2e-responsive-nav@example.com';
|
||||
const ADMIN_PASSWORD = 'ResponsiveNav123';
|
||||
|
||||
const projectRoot = join(__dirname, '../..');
|
||||
const composeFile = join(projectRoot, 'compose.yaml');
|
||||
|
||||
test.describe('Admin Responsive Navigation', () => {
|
||||
test.beforeAll(async () => {
|
||||
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' }
|
||||
);
|
||||
});
|
||||
|
||||
async function loginAsAdmin(page: import('@playwright/test').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()
|
||||
]);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// MOBILE (375×667)
|
||||
// =========================================================================
|
||||
test.describe('Mobile (375×667)', () => {
|
||||
test.use({ viewport: { width: 375, height: 667 } });
|
||||
|
||||
test('shows hamburger button and hides desktop nav', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/users`);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const hamburger = page.getByRole('button', { name: /ouvrir le menu/i });
|
||||
await expect(hamburger).toBeVisible();
|
||||
|
||||
const desktopNav = page.locator('.desktop-nav');
|
||||
await expect(desktopNav).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('displays current section label', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/users`);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const label = page.locator('.mobile-section-label');
|
||||
await expect(label).toBeVisible();
|
||||
await expect(label).toHaveText('Utilisateurs');
|
||||
});
|
||||
|
||||
test('opens and closes menu via hamburger button', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/users`);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const hamburger = page.getByRole('button', { name: /ouvrir le menu/i });
|
||||
|
||||
// Open
|
||||
await hamburger.click();
|
||||
const drawer = page.locator('[role="dialog"][aria-modal="true"]');
|
||||
await expect(drawer).toBeVisible();
|
||||
|
||||
// Close via × button
|
||||
const closeButton = page.getByRole('button', { name: /fermer le menu/i });
|
||||
await closeButton.click();
|
||||
await expect(drawer).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('closes menu on overlay click', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/users`);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await page.getByRole('button', { name: /ouvrir le menu/i }).click();
|
||||
const drawer = page.locator('[role="dialog"][aria-modal="true"]');
|
||||
await expect(drawer).toBeVisible();
|
||||
|
||||
// Click overlay (outside drawer)
|
||||
const overlay = page.locator('.mobile-overlay');
|
||||
await overlay.click({ position: { x: 350, y: 300 } });
|
||||
await expect(drawer).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('closes menu on Escape key', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/users`);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await page.getByRole('button', { name: /ouvrir le menu/i }).click();
|
||||
const drawer = page.locator('[role="dialog"][aria-modal="true"]');
|
||||
await expect(drawer).toBeVisible();
|
||||
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(drawer).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('shows active state for current section in mobile menu', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/users`);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await page.getByRole('button', { name: /ouvrir le menu/i }).click();
|
||||
const drawer = page.locator('[role="dialog"][aria-modal="true"]');
|
||||
await expect(drawer).toBeVisible();
|
||||
|
||||
const activeLink = drawer.locator('.mobile-nav-link.active');
|
||||
await expect(activeLink).toHaveText('Utilisateurs');
|
||||
});
|
||||
|
||||
test('navigates via mobile menu and closes it', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/users`);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// Open menu and click Classes
|
||||
await page.getByRole('button', { name: /ouvrir le menu/i }).click();
|
||||
const drawer = page.locator('[role="dialog"][aria-modal="true"]');
|
||||
await expect(drawer).toBeVisible();
|
||||
|
||||
await drawer.getByRole('link', { name: 'Classes' }).click();
|
||||
|
||||
// Menu should close and page should navigate
|
||||
await expect(drawer).not.toBeVisible();
|
||||
await expect(page).toHaveURL(/\/admin\/classes/);
|
||||
|
||||
// Section label should update
|
||||
const label = page.locator('.mobile-section-label');
|
||||
await expect(label).toHaveText('Classes');
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// TABLET (768×1024)
|
||||
// =========================================================================
|
||||
test.describe('Tablet (768×1024)', () => {
|
||||
test.use({ viewport: { width: 768, height: 1024 } });
|
||||
|
||||
test('shows hamburger button (below 1200px)', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/users`);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const hamburger = page.getByRole('button', { name: /ouvrir le menu/i });
|
||||
await expect(hamburger).toBeVisible();
|
||||
|
||||
const desktopNav = page.locator('.desktop-nav');
|
||||
await expect(desktopNav).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('drawer opens and works', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/users`);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await page.getByRole('button', { name: /ouvrir le menu/i }).click();
|
||||
const drawer = page.locator('[role="dialog"][aria-modal="true"]');
|
||||
await expect(drawer).toBeVisible();
|
||||
|
||||
// All nav links should be visible in drawer
|
||||
await expect(drawer.getByRole('link', { name: 'Utilisateurs' })).toBeVisible();
|
||||
await expect(drawer.getByRole('link', { name: 'Classes' })).toBeVisible();
|
||||
await expect(drawer.getByRole('link', { name: 'Matières' })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
// =========================================================================
|
||||
// DESKTOP (1280×800)
|
||||
// =========================================================================
|
||||
test.describe('Desktop (1280×800)', () => {
|
||||
test.use({ viewport: { width: 1280, height: 800 } });
|
||||
|
||||
test('hides hamburger and shows desktop nav', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/users`);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const hamburger = page.getByRole('button', { name: /ouvrir le menu/i });
|
||||
await expect(hamburger).not.toBeVisible();
|
||||
|
||||
const desktopNav = page.locator('.desktop-nav');
|
||||
await expect(desktopNav).toBeVisible();
|
||||
});
|
||||
|
||||
test('desktop nav shows all navigation links', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/users`);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const nav = page.locator('.desktop-nav');
|
||||
await expect(nav.getByRole('link', { name: 'Utilisateurs' })).toBeVisible();
|
||||
await expect(nav.getByRole('link', { name: 'Classes' })).toBeVisible();
|
||||
await expect(nav.getByRole('link', { name: 'Matières' })).toBeVisible();
|
||||
await expect(nav.getByRole('link', { name: 'Affectations' })).toBeVisible();
|
||||
await expect(nav.getByRole('link', { name: 'Périodes' })).toBeVisible();
|
||||
await expect(nav.getByRole('link', { name: 'Pédagogie' })).toBeVisible();
|
||||
});
|
||||
|
||||
test('hides mobile section label', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/users`);
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
const label = page.locator('.mobile-section-label');
|
||||
await expect(label).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -284,12 +284,12 @@ test.describe('Periods Management (Story 2.3)', () => {
|
||||
// Navigation
|
||||
// ============================================================================
|
||||
test.describe('Navigation', () => {
|
||||
test('can access periods page from admin dashboard', async ({ page }) => {
|
||||
test('can access periods page from admin navigation', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin`);
|
||||
|
||||
// Click on periods card
|
||||
await page.getByRole('link', { name: /périodes scolaires/i }).click();
|
||||
// Click on periods link in the admin navigation
|
||||
await page.getByRole('link', { name: /périodes/i }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/admin\/academic-year\/periods/);
|
||||
await expect(page.getByRole('heading', { name: /périodes scolaires/i })).toBeVisible();
|
||||
|
||||
@@ -96,9 +96,10 @@ test.describe('Role-Based Access Control [P0]', () => {
|
||||
await loginAs(page, ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||
await page.goto(`${ALPHA_URL}/admin`);
|
||||
|
||||
await expect(page).toHaveURL(/\/admin/);
|
||||
// /admin redirects to /admin/users
|
||||
await expect(page).toHaveURL(/\/admin\/users/);
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /administration/i })
|
||||
page.getByRole('heading', { name: /gestion des utilisateurs/i })
|
||||
).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
});
|
||||
@@ -191,8 +192,8 @@ test.describe('Role-Based Access Control [P0]', () => {
|
||||
await loginAs(page, ADMIN_EMAIL, ADMIN_PASSWORD);
|
||||
await page.goto(`${ALPHA_URL}/admin`);
|
||||
|
||||
// Admin layout should show navigation links (scoped to header nav to avoid action cards)
|
||||
const nav = page.locator('.header-nav');
|
||||
// Admin layout should show navigation links (scoped to desktop nav to avoid action cards)
|
||||
const nav = page.locator('.desktop-nav');
|
||||
await expect(nav.getByRole('link', { name: 'Utilisateurs' })).toBeVisible({ timeout: 15000 });
|
||||
await expect(nav.getByRole('link', { name: 'Classes' })).toBeVisible();
|
||||
});
|
||||
|
||||
278
frontend/e2e/teacher-replacements.spec.ts
Normal file
278
frontend/e2e/teacher-replacements.spec.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
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 ADMIN_EMAIL = 'e2e-replacements-admin@example.com';
|
||||
const ADMIN_PASSWORD = 'ReplacementsTest123';
|
||||
const TENANT_ID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
|
||||
|
||||
const projectRoot = join(__dirname, '../..');
|
||||
const composeFile = join(projectRoot, 'compose.yaml');
|
||||
|
||||
function runCommand(sql: string) {
|
||||
execSync(
|
||||
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "${sql}" 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
}
|
||||
|
||||
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, academicYearId };
|
||||
}
|
||||
|
||||
async function loginAsAdmin(page: import('@playwright/test').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 waitForPageReady(page: import('@playwright/test').Page) {
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /remplacements enseignants/i })
|
||||
).toBeVisible({ timeout: 15000 });
|
||||
await expect(
|
||||
page.locator('.empty-state, .replacements-table, .alert-error')
|
||||
).toBeVisible({ timeout: 15000 });
|
||||
}
|
||||
|
||||
async function openCreateDialog(page: import('@playwright/test').Page) {
|
||||
const button = page.getByRole('button', { name: /nouveau remplacement/i }).first();
|
||||
await expect(button).toBeEnabled();
|
||||
await button.click();
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 });
|
||||
}
|
||||
|
||||
function getTodayDate(): string {
|
||||
return new Date().toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
function getFutureDate(days: number): string {
|
||||
const date = new Date();
|
||||
date.setDate(date.getDate() + days);
|
||||
return date.toISOString().split('T')[0];
|
||||
}
|
||||
|
||||
test.describe('Teacher Replacements (Story 2.9)', () => {
|
||||
test.beforeAll(async () => {
|
||||
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=e2e-replaced-teacher@example.com --password=TeacherTest123 --role=ROLE_PROF 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=e2e-replacement-teacher@example.com --password=TeacherTest123 --role=ROLE_PROF 2>&1`,
|
||||
{ encoding: 'utf-8' }
|
||||
);
|
||||
|
||||
const { schoolId, academicYearId } = resolveDeterministicIds();
|
||||
|
||||
runCommand(
|
||||
`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-Repl-6B', '6ème', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
|
||||
);
|
||||
|
||||
runCommand(
|
||||
`INSERT INTO subjects (id, tenant_id, school_id, name, code, status, created_at, updated_at) VALUES (gen_random_uuid(), '${TENANT_ID}', '${schoolId}', 'E2E-Repl-Français', 'E2EFRA', 'active', NOW(), NOW()) ON CONFLICT DO NOTHING`
|
||||
);
|
||||
});
|
||||
|
||||
test.beforeEach(async () => {
|
||||
try {
|
||||
runCommand(`DELETE FROM replacement_classes WHERE replacement_id IN (SELECT id FROM teacher_replacements WHERE tenant_id = '${TENANT_ID}')`);
|
||||
runCommand(`DELETE FROM teacher_replacements WHERE tenant_id = '${TENANT_ID}'`);
|
||||
} catch {
|
||||
// Tables may not exist yet if migration hasn't run
|
||||
}
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Navigation
|
||||
// ============================================================================
|
||||
test.describe('Navigation', () => {
|
||||
test('replacements link appears in admin navigation', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin`);
|
||||
|
||||
const navLink = page.getByRole('link', { name: /remplacements/i });
|
||||
await expect(navLink).toBeVisible({ timeout: 15000 });
|
||||
});
|
||||
|
||||
test('can navigate to replacements page', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/replacements`);
|
||||
|
||||
await expect(page.getByRole('heading', { name: /remplacements enseignants/i })).toBeVisible({ timeout: 15000 });
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Empty State
|
||||
// ============================================================================
|
||||
test.describe('Empty State', () => {
|
||||
test('shows empty state when no replacements exist', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/replacements`);
|
||||
|
||||
await waitForPageReady(page);
|
||||
await expect(page.getByText(/aucun remplacement actif/i)).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// AC1: Create Replacement
|
||||
// ============================================================================
|
||||
test.describe('AC1: Create Replacement', () => {
|
||||
test('can create a new replacement', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/replacements`);
|
||||
await waitForPageReady(page);
|
||||
|
||||
await openCreateDialog(page);
|
||||
|
||||
// Select replaced teacher
|
||||
const replacedSelect = page.locator('#replaced-teacher');
|
||||
await expect(replacedSelect).toBeVisible();
|
||||
await replacedSelect.selectOption({ index: 1 });
|
||||
|
||||
// Select replacement teacher (different from replaced)
|
||||
const replacementSelect = page.locator('#replacement-teacher');
|
||||
await expect(replacementSelect).toBeVisible();
|
||||
const replacementOptions = replacementSelect.locator('option');
|
||||
const count = await replacementOptions.count();
|
||||
// Select a different teacher (index 1 should work since replaced teacher is filtered out)
|
||||
if (count > 1) {
|
||||
await replacementSelect.selectOption({ index: 1 });
|
||||
}
|
||||
|
||||
// Set dates
|
||||
await page.locator('#start-date').fill(getTodayDate());
|
||||
await page.locator('#end-date').fill(getFutureDate(30));
|
||||
|
||||
// Select class and subject
|
||||
const firstClassSelect = page.locator('.class-pair-row select').first();
|
||||
await firstClassSelect.selectOption({ index: 1 });
|
||||
const firstSubjectSelect = page.locator('.class-pair-row select').nth(1);
|
||||
await firstSubjectSelect.selectOption({ index: 1 });
|
||||
|
||||
// Submit
|
||||
await page.getByRole('button', { name: /désigner le remplaçant/i }).click();
|
||||
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByText(/remplacement créé/i)).toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Verify table shows the replacement
|
||||
const table = page.locator('.replacements-table');
|
||||
await expect(table).toBeVisible({ timeout: 10000 });
|
||||
const rows = table.locator('tbody tr');
|
||||
const rowCount = await rows.count();
|
||||
expect(rowCount).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
test('cancel closes the modal without creating', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/replacements`);
|
||||
await waitForPageReady(page);
|
||||
|
||||
await openCreateDialog(page);
|
||||
|
||||
await page.getByRole('button', { name: /annuler/i }).click();
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 5000 });
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// AC3: End Replacement
|
||||
// ============================================================================
|
||||
test.describe('AC3: End Replacement', () => {
|
||||
test('can end an active replacement', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/replacements`);
|
||||
await waitForPageReady(page);
|
||||
|
||||
// First create a replacement
|
||||
await openCreateDialog(page);
|
||||
await page.locator('#replaced-teacher').selectOption({ index: 1 });
|
||||
await page.locator('#replacement-teacher').selectOption({ index: 1 });
|
||||
await page.locator('#start-date').fill(getTodayDate());
|
||||
await page.locator('#end-date').fill(getFutureDate(30));
|
||||
const firstClassSelect = page.locator('.class-pair-row select').first();
|
||||
await firstClassSelect.selectOption({ index: 1 });
|
||||
const firstSubjectSelect = page.locator('.class-pair-row select').nth(1);
|
||||
await firstSubjectSelect.selectOption({ index: 1 });
|
||||
await page.getByRole('button', { name: /désigner le remplaçant/i }).click();
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Now end it
|
||||
const endButton = page.locator('.btn-remove').first();
|
||||
await endButton.click();
|
||||
|
||||
const confirmDialog = page.getByRole('alertdialog');
|
||||
await expect(confirmDialog).toBeVisible({ timeout: 5000 });
|
||||
await expect(page.getByText(/perdra immédiatement l'accès/i)).toBeVisible();
|
||||
|
||||
await confirmDialog.getByRole('button', { name: /terminer/i }).click();
|
||||
|
||||
await expect(confirmDialog).not.toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByText(/remplacement terminé/i)).toBeVisible({ timeout: 10000 });
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// AC4: Active Replacements Display
|
||||
// ============================================================================
|
||||
test.describe('AC4: Active Replacements Display', () => {
|
||||
test('shows countdown for active replacements', async ({ page }) => {
|
||||
await loginAsAdmin(page);
|
||||
await page.goto(`${ALPHA_URL}/admin/replacements`);
|
||||
await waitForPageReady(page);
|
||||
|
||||
// Create a replacement
|
||||
await openCreateDialog(page);
|
||||
await page.locator('#replaced-teacher').selectOption({ index: 1 });
|
||||
await page.locator('#replacement-teacher').selectOption({ index: 1 });
|
||||
await page.locator('#start-date').fill(getTodayDate());
|
||||
await page.locator('#end-date').fill(getFutureDate(10));
|
||||
const firstClassSelect = page.locator('.class-pair-row select').first();
|
||||
await firstClassSelect.selectOption({ index: 1 });
|
||||
const firstSubjectSelect = page.locator('.class-pair-row select').nth(1);
|
||||
await firstSubjectSelect.selectOption({ index: 1 });
|
||||
await page.getByRole('button', { name: /désigner le remplaçant/i }).click();
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
|
||||
|
||||
// Verify countdown is displayed
|
||||
await expect(page.locator('.countdown')).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByText(/jours? restants?/i)).toBeVisible();
|
||||
|
||||
// Verify status badge
|
||||
await expect(page.locator('.status-active')).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -46,6 +46,11 @@
|
||||
<span class="action-label">Affectations</span>
|
||||
<span class="action-hint">Enseignants et classes</span>
|
||||
</a>
|
||||
<a class="action-card" href="/admin/replacements">
|
||||
<span class="action-icon">🔄</span>
|
||||
<span class="action-label">Remplacements</span>
|
||||
<span class="action-hint">Enseignants absents</span>
|
||||
</a>
|
||||
<a class="action-card" href="/admin/academic-year/periods">
|
||||
<span class="action-icon">📅</span>
|
||||
<span class="action-label">Périodes scolaires</span>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<script lang="ts">
|
||||
import DashboardSection from '$lib/components/molecules/DashboardSection.svelte';
|
||||
import SkeletonList from '$lib/components/atoms/Skeleton/SkeletonList.svelte';
|
||||
import { getApiBaseUrl } from '$lib/api/config';
|
||||
import { authenticatedFetch, isAuthenticated } from '$lib/auth';
|
||||
import { untrack } from 'svelte';
|
||||
|
||||
let {
|
||||
isLoading = false,
|
||||
@@ -9,6 +12,53 @@
|
||||
isLoading?: boolean;
|
||||
hasRealData?: boolean;
|
||||
} = $props();
|
||||
|
||||
interface ReplacedClass {
|
||||
replacementId: string;
|
||||
replacedTeacherId: string;
|
||||
classId: string;
|
||||
subjectId: string;
|
||||
className: string;
|
||||
subjectName: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
}
|
||||
|
||||
let replacedClasses = $state<ReplacedClass[]>([]);
|
||||
let replacementsLoading = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
untrack(() => {
|
||||
if (isAuthenticated()) {
|
||||
loadReplacedClasses();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function loadReplacedClasses() {
|
||||
try {
|
||||
replacementsLoading = true;
|
||||
const apiUrl = getApiBaseUrl();
|
||||
const response = await authenticatedFetch(`${apiUrl}/me/replaced-classes`);
|
||||
|
||||
if (!response.ok) return;
|
||||
|
||||
const data = await response.json();
|
||||
replacedClasses = Array.isArray(data) ? data : (data['hydra:member'] ?? []);
|
||||
} catch {
|
||||
// Silently fail - not critical for dashboard display
|
||||
} finally {
|
||||
replacementsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function daysRemaining(endDate: string): number {
|
||||
const end = new Date(endDate);
|
||||
const now = new Date();
|
||||
now.setHours(0, 0, 0, 0);
|
||||
end.setHours(0, 0, 0, 0);
|
||||
return Math.ceil((end.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="dashboard-teacher">
|
||||
@@ -35,6 +85,44 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if replacedClasses.length > 0}
|
||||
<DashboardSection
|
||||
title="Classes en remplacement"
|
||||
subtitle="Vous remplacez actuellement un enseignant"
|
||||
>
|
||||
<div class="replacement-list">
|
||||
{#each replacedClasses as rc}
|
||||
{@const days = daysRemaining(rc.endDate)}
|
||||
<div class="replacement-card">
|
||||
<div class="replacement-badge">Remplacement</div>
|
||||
<div class="replacement-info">
|
||||
<span class="replacement-class">{rc.className}</span>
|
||||
<span class="replacement-subject">{rc.subjectName}</span>
|
||||
</div>
|
||||
<div class="replacement-dates">
|
||||
{new Date(rc.startDate).toLocaleDateString('fr-FR')}
|
||||
→
|
||||
{new Date(rc.endDate).toLocaleDateString('fr-FR')}
|
||||
<span class="replacement-countdown" class:urgent={days <= 3}>
|
||||
{#if days > 1}
|
||||
({days} jours restants)
|
||||
{:else if days === 1}
|
||||
(1 jour restant)
|
||||
{:else}
|
||||
(Dernier jour)
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</DashboardSection>
|
||||
{:else if replacementsLoading}
|
||||
<DashboardSection title="Classes en remplacement">
|
||||
<SkeletonList items={2} message="Chargement des remplacements..." />
|
||||
</DashboardSection>
|
||||
{/if}
|
||||
|
||||
<div class="dashboard-grid">
|
||||
<DashboardSection
|
||||
title="Mes classes aujourd'hui"
|
||||
@@ -164,6 +252,62 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
/* Replacement section */
|
||||
.replacement-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.replacement-card {
|
||||
padding: 0.75rem 1rem;
|
||||
background: #eff6ff;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.replacement-badge {
|
||||
display: inline-block;
|
||||
padding: 0.125rem 0.5rem;
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.replacement-info {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.replacement-class {
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.replacement-subject {
|
||||
color: #4b5563;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.replacement-dates {
|
||||
font-size: 0.8125rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.replacement-countdown {
|
||||
font-weight: 500;
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.replacement-countdown.urgent {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.dashboard-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
let isLoggingOut = $state(false);
|
||||
let accessChecked = $state(false);
|
||||
let hasAccess = $state(false);
|
||||
let mobileMenuOpen = $state(false);
|
||||
|
||||
const ADMIN_ROLES = [
|
||||
'ROLE_SUPER_ADMIN',
|
||||
@@ -18,6 +19,17 @@
|
||||
'ROLE_SECRETARIAT'
|
||||
];
|
||||
|
||||
const navLinks = [
|
||||
{ href: '/dashboard', label: 'Tableau de bord', isActive: () => false },
|
||||
{ href: '/admin/users', label: 'Utilisateurs', isActive: () => isUsersActive },
|
||||
{ href: '/admin/classes', label: 'Classes', isActive: () => isClassesActive },
|
||||
{ href: '/admin/subjects', label: 'Matières', isActive: () => isSubjectsActive },
|
||||
{ href: '/admin/assignments', label: 'Affectations', isActive: () => isAssignmentsActive },
|
||||
{ href: '/admin/replacements', label: 'Remplacements', isActive: () => isReplacementsActive },
|
||||
{ href: '/admin/academic-year/periods', label: 'Périodes', isActive: () => isPeriodsActive },
|
||||
{ href: '/admin/pedagogy', label: 'Pédagogie', isActive: () => isPedagogyActive }
|
||||
];
|
||||
|
||||
// Load user roles and verify admin access
|
||||
onMount(async () => {
|
||||
await fetchRoles();
|
||||
@@ -51,13 +63,65 @@
|
||||
goto('/settings');
|
||||
}
|
||||
|
||||
function toggleMobileMenu() {
|
||||
mobileMenuOpen = !mobileMenuOpen;
|
||||
}
|
||||
|
||||
function closeMobileMenu() {
|
||||
mobileMenuOpen = false;
|
||||
}
|
||||
|
||||
// Determine which admin section is active
|
||||
const isUsersActive = $derived(page.url.pathname.startsWith('/admin/users'));
|
||||
const isClassesActive = $derived(page.url.pathname.startsWith('/admin/classes'));
|
||||
const isSubjectsActive = $derived(page.url.pathname.startsWith('/admin/subjects'));
|
||||
const isPeriodsActive = $derived(page.url.pathname.startsWith('/admin/academic-year/periods'));
|
||||
const isAssignmentsActive = $derived(page.url.pathname.startsWith('/admin/assignments'));
|
||||
const isReplacementsActive = $derived(page.url.pathname.startsWith('/admin/replacements'));
|
||||
const isPedagogyActive = $derived(page.url.pathname.startsWith('/admin/pedagogy'));
|
||||
|
||||
const currentSectionLabel = $derived.by(() => {
|
||||
const path = page.url.pathname;
|
||||
for (const link of navLinks) {
|
||||
if (link.href !== '/dashboard' && path.startsWith(link.href)) {
|
||||
return link.label;
|
||||
}
|
||||
}
|
||||
return 'Administration';
|
||||
});
|
||||
|
||||
// Close menu on route change
|
||||
$effect(() => {
|
||||
void page.url.pathname;
|
||||
mobileMenuOpen = false;
|
||||
});
|
||||
|
||||
// Close menu on Escape key
|
||||
$effect(() => {
|
||||
if (!mobileMenuOpen) return;
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
mobileMenuOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeydown);
|
||||
return () => document.removeEventListener('keydown', handleKeydown);
|
||||
});
|
||||
|
||||
// Lock body scroll when menu is open
|
||||
$effect(() => {
|
||||
if (mobileMenuOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if !accessChecked}
|
||||
@@ -71,15 +135,25 @@
|
||||
<button class="logo-button" onclick={goHome}>
|
||||
<span class="logo-text">Classeo</span>
|
||||
</button>
|
||||
<nav class="header-nav">
|
||||
|
||||
<span class="mobile-section-label">{currentSectionLabel}</span>
|
||||
|
||||
<button
|
||||
class="hamburger-button"
|
||||
onclick={toggleMobileMenu}
|
||||
aria-expanded={mobileMenuOpen}
|
||||
aria-label="Ouvrir le menu de navigation"
|
||||
>
|
||||
<span class="hamburger-line"></span>
|
||||
<span class="hamburger-line"></span>
|
||||
<span class="hamburger-line"></span>
|
||||
</button>
|
||||
|
||||
<nav class="desktop-nav">
|
||||
<RoleSwitcher />
|
||||
<a href="/dashboard" class="nav-link">Tableau de bord</a>
|
||||
<a href="/admin/users" class="nav-link" class:active={isUsersActive}>Utilisateurs</a>
|
||||
<a href="/admin/classes" class="nav-link" class:active={isClassesActive}>Classes</a>
|
||||
<a href="/admin/subjects" class="nav-link" class:active={isSubjectsActive}>Matières</a>
|
||||
<a href="/admin/assignments" class="nav-link" class:active={isAssignmentsActive}>Affectations</a>
|
||||
<a href="/admin/academic-year/periods" class="nav-link" class:active={isPeriodsActive}>Périodes</a>
|
||||
<a href="/admin/pedagogy" class="nav-link" class:active={isPedagogyActive}>Pédagogie</a>
|
||||
{#each navLinks as link}
|
||||
<a href={link.href} class="nav-link" class:active={link.isActive()}>{link.label}</a>
|
||||
{/each}
|
||||
<button class="nav-button" onclick={goSettings}>Paramètres</button>
|
||||
<button class="logout-button" onclick={handleLogout} disabled={isLoggingOut}>
|
||||
{#if isLoggingOut}
|
||||
@@ -93,6 +167,60 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if mobileMenuOpen}
|
||||
<div
|
||||
class="mobile-overlay"
|
||||
onclick={closeMobileMenu}
|
||||
onkeydown={(e) => e.key === 'Enter' && closeMobileMenu()}
|
||||
role="presentation"
|
||||
></div>
|
||||
<div
|
||||
class="mobile-drawer"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Menu de navigation"
|
||||
>
|
||||
<div class="mobile-drawer-header">
|
||||
<span class="logo-text">Classeo</span>
|
||||
<button
|
||||
class="mobile-close"
|
||||
onclick={closeMobileMenu}
|
||||
aria-label="Fermer le menu"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div class="mobile-drawer-body">
|
||||
<div class="mobile-role-switcher">
|
||||
<RoleSwitcher />
|
||||
</div>
|
||||
{#each navLinks as link}
|
||||
<a
|
||||
href={link.href}
|
||||
class="mobile-nav-link"
|
||||
class:active={link.isActive()}
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="mobile-drawer-footer">
|
||||
<button class="mobile-nav-link" onclick={goSettings}>Paramètres</button>
|
||||
<button
|
||||
class="mobile-nav-link mobile-logout"
|
||||
onclick={handleLogout}
|
||||
disabled={isLoggingOut}
|
||||
>
|
||||
{#if isLoggingOut}
|
||||
Déconnexion...
|
||||
{:else}
|
||||
Déconnexion
|
||||
{/if}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<main class="admin-main">
|
||||
<div class="main-content">
|
||||
{@render children()}
|
||||
@@ -131,9 +259,8 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
height: auto;
|
||||
padding: 0.75rem 0;
|
||||
height: 56px;
|
||||
padding: 0;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
@@ -150,23 +277,64 @@
|
||||
color: var(--accent-primary, #0ea5e9);
|
||||
}
|
||||
|
||||
.header-nav {
|
||||
/* Mobile section label */
|
||||
.mobile-section-label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1f2937);
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Hamburger button */
|
||||
.hamburger-button {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
gap: 5px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
border-radius: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hamburger-button:hover {
|
||||
background: var(--surface-primary, #f8fafc);
|
||||
}
|
||||
|
||||
.hamburger-line {
|
||||
display: block;
|
||||
width: 20px;
|
||||
height: 2px;
|
||||
background: var(--text-secondary, #64748b);
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
/* Desktop nav — hidden on mobile */
|
||||
.desktop-nav {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
padding: 0.375rem 0.625rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #64748b);
|
||||
text-decoration: none;
|
||||
border-radius: 0.5rem;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
@@ -180,8 +348,8 @@
|
||||
}
|
||||
|
||||
.nav-button {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
padding: 0.375rem 0.625rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #64748b);
|
||||
background: transparent;
|
||||
@@ -189,6 +357,8 @@
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nav-button:hover {
|
||||
@@ -199,9 +369,9 @@
|
||||
.logout-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.625rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #64748b);
|
||||
background: transparent;
|
||||
@@ -209,6 +379,8 @@
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.logout-button:hover:not(:disabled) {
|
||||
@@ -221,6 +393,118 @@
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Mobile overlay */
|
||||
.mobile-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
z-index: 200;
|
||||
animation: fadeIn 0.2s ease-out;
|
||||
}
|
||||
|
||||
/* Mobile drawer */
|
||||
.mobile-drawer {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: min(320px, 85vw);
|
||||
background: var(--surface-elevated, #fff);
|
||||
z-index: 201;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
animation: slideInLeft 0.25s ease-out;
|
||||
}
|
||||
|
||||
.mobile-drawer-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid var(--border-subtle, #e2e8f0);
|
||||
}
|
||||
|
||||
.mobile-close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 1.5rem;
|
||||
color: var(--text-secondary, #64748b);
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.mobile-close:hover {
|
||||
background: var(--surface-primary, #f8fafc);
|
||||
color: var(--text-primary, #1f2937);
|
||||
}
|
||||
|
||||
.mobile-drawer-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.75rem 0;
|
||||
}
|
||||
|
||||
.mobile-role-switcher {
|
||||
padding: 0.5rem 1.25rem 0.75rem;
|
||||
border-bottom: 1px solid var(--border-subtle, #e2e8f0);
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.mobile-nav-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 0.75rem 1.25rem;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #64748b);
|
||||
text-decoration: none;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
border-left: 3px solid transparent;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.mobile-nav-link:hover {
|
||||
background: var(--surface-primary, #f8fafc);
|
||||
color: var(--text-primary, #1f2937);
|
||||
}
|
||||
|
||||
.mobile-nav-link.active {
|
||||
color: var(--accent-primary, #0ea5e9);
|
||||
border-left-color: var(--accent-primary, #0ea5e9);
|
||||
background: var(--accent-primary-light, #e0f2fe);
|
||||
}
|
||||
|
||||
.mobile-logout {
|
||||
color: var(--color-alert, #ef4444);
|
||||
}
|
||||
|
||||
.mobile-logout:hover {
|
||||
background: #fef2f2;
|
||||
}
|
||||
|
||||
.mobile-drawer-footer {
|
||||
border-top: 1px solid var(--border-subtle, #e2e8f0);
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slideInLeft {
|
||||
from { transform: translateX(-100%); }
|
||||
to { transform: translateX(0); }
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
@@ -246,18 +530,33 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
@media (min-width: 1200px) {
|
||||
.header-content {
|
||||
flex-wrap: nowrap;
|
||||
height: 64px;
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.header-nav {
|
||||
width: auto;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: flex-start;
|
||||
.mobile-section-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hamburger-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.desktop-nav {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.nav-link,
|
||||
.nav-button {
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.logout-button {
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.admin-main {
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { getApiBaseUrl } from '$lib/api/config';
|
||||
import { authenticatedFetch } from '$lib/auth/auth.svelte';
|
||||
|
||||
let classCount = $state<number | null>(null);
|
||||
let subjectCount = $state<number | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
loadStats();
|
||||
});
|
||||
|
||||
async function loadStats() {
|
||||
const base = getApiBaseUrl();
|
||||
|
||||
const [classesRes, subjectsRes] = await Promise.allSettled([
|
||||
authenticatedFetch(`${base}/classes`),
|
||||
authenticatedFetch(`${base}/subjects`)
|
||||
]);
|
||||
|
||||
if (classesRes.status === 'fulfilled' && classesRes.value.ok) {
|
||||
const data = await classesRes.value.json();
|
||||
classCount = Array.isArray(data) ? data.length : (data['hydra:totalItems'] ?? null);
|
||||
}
|
||||
|
||||
if (subjectsRes.status === 'fulfilled' && subjectsRes.value.ok) {
|
||||
const data = await subjectsRes.value.json();
|
||||
subjectCount = Array.isArray(data) ? data.length : (data['hydra:totalItems'] ?? null);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Administration - Classeo</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="admin-dashboard">
|
||||
<header class="page-header">
|
||||
<h1>Administration</h1>
|
||||
<p class="subtitle">Configurez votre établissement</p>
|
||||
</header>
|
||||
|
||||
<div class="stats-row">
|
||||
<div class="stat-card">
|
||||
<span class="stat-value">{classCount ?? '–'}</span>
|
||||
<span class="stat-label">Classes</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-value">{subjectCount ?? '–'}</span>
|
||||
<span class="stat-label">Matières</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action-cards">
|
||||
<a class="action-card" href="/admin/classes">
|
||||
<span class="action-icon">🏫</span>
|
||||
<span class="action-label">Classes</span>
|
||||
<span class="action-hint">Créer et gérer les classes</span>
|
||||
</a>
|
||||
<a class="action-card" href="/admin/subjects">
|
||||
<span class="action-icon">📚</span>
|
||||
<span class="action-label">Matières</span>
|
||||
<span class="action-hint">Créer et gérer les matières</span>
|
||||
</a>
|
||||
<a class="action-card" href="/admin/academic-year/periods">
|
||||
<span class="action-icon">📅</span>
|
||||
<span class="action-label">Périodes scolaires</span>
|
||||
<span class="action-hint">Trimestres ou semestres</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.admin-dashboard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1f2937);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0.25rem 0 0;
|
||||
color: var(--text-secondary, #64748b);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 1rem 1.5rem;
|
||||
background: var(--surface-elevated, #fff);
|
||||
border: 1px solid var(--border-subtle, #e2e8f0);
|
||||
border-radius: 0.75rem;
|
||||
min-width: 100px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1f2937);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #64748b);
|
||||
}
|
||||
|
||||
.action-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.action-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1.5rem 1rem;
|
||||
background: var(--surface-elevated, #fff);
|
||||
border: 2px solid var(--border-subtle, #e2e8f0);
|
||||
border-radius: 0.75rem;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.action-card:hover {
|
||||
border-color: var(--accent-primary, #0ea5e9);
|
||||
background: var(--accent-primary-light, #e0f2fe);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.action-label {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #374151);
|
||||
}
|
||||
|
||||
.action-hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.stats-row {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
5
frontend/src/routes/admin/+page.ts
Normal file
5
frontend/src/routes/admin/+page.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
export function load() {
|
||||
redirect(302, '/admin/users');
|
||||
}
|
||||
@@ -352,11 +352,11 @@
|
||||
<tbody>
|
||||
{#each assignments as assignment (assignment.id)}
|
||||
<tr>
|
||||
<td class="teacher-cell">
|
||||
<td data-label="Enseignant" class="teacher-cell">
|
||||
<span class="teacher-name">{assignment.teacherFirstName} {assignment.teacherLastName}</span>
|
||||
</td>
|
||||
<td>{assignment.className}</td>
|
||||
<td>
|
||||
<td data-label="Classe">{assignment.className}</td>
|
||||
<td data-label="Matière">
|
||||
{#if getSubjectColor(assignment.subjectId)}
|
||||
<span
|
||||
class="subject-badge"
|
||||
@@ -368,13 +368,13 @@
|
||||
{assignment.subjectName}
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
<td data-label="Statut">
|
||||
<span class="status-badge status-active">Active</span>
|
||||
</td>
|
||||
<td class="date-cell">
|
||||
<td data-label="Depuis le" class="date-cell">
|
||||
{new Date(assignment.startDate).toLocaleDateString('fr-FR')}
|
||||
</td>
|
||||
<td class="actions-cell">
|
||||
<td data-label="Actions" class="actions-cell">
|
||||
<button
|
||||
class="btn-remove"
|
||||
onclick={() => openDeleteModal(assignment)}
|
||||
@@ -916,12 +916,58 @@
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.assignments-table th:nth-child(4),
|
||||
.assignments-table td:nth-child(4),
|
||||
.assignments-table th:nth-child(5),
|
||||
.assignments-table td:nth-child(5) {
|
||||
display: table-cell;
|
||||
@media (max-width: 767px) {
|
||||
.table-container {
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.assignments-table thead {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.assignments-table tbody tr {
|
||||
display: block;
|
||||
background: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.assignments-table tr:hover td {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.assignments-table td {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
padding: 0.5rem 1rem;
|
||||
border-bottom: none;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.assignments-table td::before {
|
||||
content: attr(data-label);
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
text-align: left;
|
||||
margin-right: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.teacher-cell {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.actions-cell {
|
||||
justify-content: flex-end;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid #f3f4f6;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
1268
frontend/src/routes/admin/replacements/+page.svelte
Normal file
1268
frontend/src/routes/admin/replacements/+page.svelte
Normal file
File diff suppressed because it is too large
Load Diff
@@ -569,11 +569,11 @@
|
||||
<tbody>
|
||||
{#each users as user (user.id)}
|
||||
<tr>
|
||||
<td class="user-name-cell">
|
||||
<td data-label="Nom" class="user-name-cell">
|
||||
<span class="user-fullname">{user.firstName} {user.lastName}</span>
|
||||
</td>
|
||||
<td class="user-email">{user.email}</td>
|
||||
<td>
|
||||
<td data-label="Email" class="user-email">{user.email}</td>
|
||||
<td data-label="Rôle">
|
||||
<div class="role-badges">
|
||||
{#if user.roles && user.roles.length > 0}
|
||||
{#each user.roles as roleValue}
|
||||
@@ -584,7 +584,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<td data-label="Statut">
|
||||
<span class="status-badge {getStatutClass(user.statut, user.invitationExpiree)}">
|
||||
{getStatutDisplay(user.statut, user.invitationExpiree)}
|
||||
</span>
|
||||
@@ -594,8 +594,8 @@
|
||||
</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="date-cell">{formatDate(user.invitedAt)}</td>
|
||||
<td class="actions-cell">
|
||||
<td data-label="Invitation" class="date-cell">{formatDate(user.invitedAt)}</td>
|
||||
<td data-label="Actions" class="actions-cell">
|
||||
{#if user.id !== getCurrentUserId()}
|
||||
<button
|
||||
class="btn-secondary btn-sm"
|
||||
@@ -1196,11 +1196,6 @@
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.users-table th:nth-child(5),
|
||||
.users-table td:nth-child(5) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.user-name-cell {
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -1481,10 +1476,61 @@
|
||||
.form-row {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.users-table th:nth-child(5),
|
||||
.users-table td:nth-child(5) {
|
||||
display: table-cell;
|
||||
@media (max-width: 767px) {
|
||||
.users-table-container {
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.users-table thead {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.users-table tbody tr {
|
||||
display: block;
|
||||
background: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.users-table tr:hover td {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.users-table td {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
padding: 0.5rem 1rem;
|
||||
border-bottom: none;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.users-table td::before {
|
||||
content: attr(data-label);
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
text-align: left;
|
||||
margin-right: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-name-cell {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.actions-cell {
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid #f3f4f6;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user