feat: Gestion des matières scolaires

Les établissements ont besoin de définir leur référentiel de matières
pour pouvoir ensuite les associer aux enseignants et aux classes.
Cette fonctionnalité permet aux administrateurs de créer, modifier et
archiver les matières avec leurs propriétés (nom, code court, couleur).

L'architecture suit le pattern DDD avec des Value Objects utilisant
les property hooks PHP 8.5 pour garantir l'immutabilité et la validation.
L'isolation multi-tenant est assurée par vérification dans les handlers.
This commit is contained in:
2026-02-05 20:42:31 +01:00
parent 8e09e0abf1
commit 0d5a097c4c
50 changed files with 5882 additions and 0 deletions

View File

@@ -0,0 +1,653 @@
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);
// Extract port from PLAYWRIGHT_BASE_URL or use default (4173 matches playwright.config.ts)
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}`;
// Test credentials
const ADMIN_EMAIL = 'e2e-subjects-admin@example.com';
const ADMIN_PASSWORD = 'SubjectsTest123';
// Force serial execution to ensure Empty State runs first
test.describe.configure({ mode: 'serial' });
test.describe('Subjects Management (Story 2.2)', () => {
// Create admin user and clean up subjects before running tests
test.beforeAll(async () => {
const projectRoot = join(__dirname, '../..');
const composeFile = join(projectRoot, 'compose.yaml');
try {
// Create admin user
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' }
);
console.log('Subjects E2E test admin user created');
// Clean up all subjects for this tenant to ensure Empty State test works
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "DELETE FROM subjects WHERE tenant_id = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'" 2>&1`,
{ encoding: 'utf-8' }
);
console.log('Subjects cleaned up for E2E tests');
} catch (error) {
console.error('Setup error:', error);
}
});
// Helper to login as admin
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 page.getByRole('button', { name: /se connecter/i }).click();
await expect(page).toHaveURL(/\/dashboard/, { timeout: 10000 });
}
// Helper to open "Nouvelle matière" dialog with proper wait
async function openNewSubjectDialog(page: import('@playwright/test').Page) {
const button = page.getByRole('button', { name: /nouvelle matière/i });
await button.waitFor({ state: 'visible' });
// Wait for any pending network requests to finish before clicking
await page.waitForLoadState('networkidle');
// Click the button
await button.click();
// Wait for dialog to appear - retry click if needed (webkit timing issue)
const dialog = page.getByRole('dialog');
try {
await expect(dialog).toBeVisible({ timeout: 5000 });
} catch {
// Retry once - webkit sometimes needs a second click
await button.click();
await expect(dialog).toBeVisible({ timeout: 10000 });
}
}
// ============================================================================
// EMPTY STATE - Must run FIRST before any subject is created
// ============================================================================
test.describe('Empty State', () => {
test('shows empty state message when no subjects exist', async ({ page }) => {
// Clean up subjects right before this specific test to avoid race conditions with parallel browsers
const projectRoot = join(__dirname, '../..');
const composeFile = join(projectRoot, 'compose.yaml');
try {
execSync(
`docker compose -f "${composeFile}" exec -T php php bin/console dbal:run-sql "DELETE FROM subjects WHERE tenant_id = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890'" 2>&1`,
{ encoding: 'utf-8' }
);
} catch {
// Ignore cleanup errors
}
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/subjects`);
// Wait for page to load
await expect(page.getByRole('heading', { name: /gestion des matières/i })).toBeVisible();
// Should show empty state
await expect(page.locator('.empty-state')).toBeVisible();
await expect(page.getByText(/aucune matière/i)).toBeVisible();
await expect(page.getByRole('button', { name: /créer une matière/i })).toBeVisible();
});
});
// ============================================================================
// List Display
// ============================================================================
test.describe('List Display', () => {
test('displays all created subjects in the list', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/subjects`);
// Create multiple subjects
const subjects = [
{ name: `Math-${Date.now()}`, code: 'MATH1' },
{ name: `Français-${Date.now()}`, code: 'FR1' },
{ name: `Histoire-${Date.now()}`, code: 'HIST1' }
];
for (const subject of subjects) {
await openNewSubjectDialog(page);
await page.locator('#subject-name').fill(subject.name);
await page.locator('#subject-code').fill(subject.code);
await page.getByRole('button', { name: /créer la matière/i }).click();
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
}
// Verify ALL subjects appear in the list
for (const subject of subjects) {
await expect(page.getByText(subject.name)).toBeVisible();
}
// Verify the number of subject cards matches (at least the ones we created)
const subjectCards = page.locator('.subject-card');
const count = await subjectCards.count();
expect(count).toBeGreaterThanOrEqual(subjects.length);
});
test('displays subject details correctly (code, color)', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/subjects`);
// Create a subject with all details
const subjectName = `Details-${Date.now()}`;
const subjectCode = `DET${Date.now() % 10000}`;
await openNewSubjectDialog(page);
await page.locator('#subject-name').fill(subjectName);
await page.locator('#subject-code').fill(subjectCode);
// Select blue color
await page.locator('.color-swatch').first().click();
await page.getByRole('button', { name: /créer la matière/i }).click();
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
// Find the subject card
const subjectCard = page.locator('.subject-card', { hasText: subjectName });
await expect(subjectCard).toBeVisible();
// Verify code is displayed (uppercase)
await expect(subjectCard.getByText(subjectCode.toUpperCase())).toBeVisible();
});
});
// ============================================================================
// AC1: Subject Creation
// ============================================================================
test.describe('AC1: Subject Creation', () => {
test('can create a new subject with all fields', async ({ page }) => {
await loginAsAdmin(page);
// Navigate to subjects page
await page.goto(`${ALPHA_URL}/admin/subjects`);
await expect(page.getByRole('heading', { name: /gestion des matières/i })).toBeVisible();
// Click "Nouvelle matière" button
await openNewSubjectDialog(page);
await expect(page.getByRole('heading', { name: /nouvelle matière/i })).toBeVisible();
// Fill form
const uniqueName = `Test-E2E-${Date.now()}`;
const uniqueCode = `TE${Date.now() % 100000}`;
await page.locator('#subject-name').fill(uniqueName);
await page.locator('#subject-code').fill(uniqueCode);
// Select a color (blue - Mathématiques)
await page.locator('.color-swatch').first().click();
// Note: Description is only available on the edit page, not in the create modal
// Submit
await page.getByRole('button', { name: /créer la matière/i }).click();
// Modal should close and subject should appear in list
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
await expect(page.getByText(uniqueName)).toBeVisible({ timeout: 10000 });
});
test('can create a subject with only required fields (name, code)', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/subjects`);
await openNewSubjectDialog(page);
// Fill only the required fields
const uniqueName = `Minimal-${Date.now()}`;
const uniqueCode = `MIN${Date.now() % 10000}`;
await page.locator('#subject-name').fill(uniqueName);
await page.locator('#subject-code').fill(uniqueCode);
// Submit button should be enabled
const submitButton = page.getByRole('button', { name: /créer la matière/i });
await expect(submitButton).toBeEnabled();
await submitButton.click();
// Subject should be created
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
await expect(page.getByText(uniqueName)).toBeVisible({ timeout: 10000 });
});
test('submit button is disabled when required fields are empty', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/subjects`);
await openNewSubjectDialog(page);
// Don't fill anything
const submitButton = page.getByRole('button', { name: /créer la matière/i });
await expect(submitButton).toBeDisabled();
// Fill name but not code
await page.locator('#subject-name').fill('Test');
await expect(submitButton).toBeDisabled();
// Fill code - button should enable
await page.locator('#subject-code').fill('TST');
await expect(submitButton).toBeEnabled();
});
test('can cancel subject creation', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/subjects`);
await openNewSubjectDialog(page);
// Fill form
await page.locator('#subject-name').fill('Should-Not-Be-Created');
await page.locator('#subject-code').fill('NOPE');
// Click cancel
await page.getByRole('button', { name: /annuler/i }).click();
// Modal should close
await expect(page.getByRole('dialog')).not.toBeVisible();
// Subject should not appear in list
await expect(page.getByText('Should-Not-Be-Created')).not.toBeVisible();
});
test('code is automatically converted to uppercase', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/subjects`);
await openNewSubjectDialog(page);
const uniqueName = `Upper-${Date.now()}`;
await page.locator('#subject-name').fill(uniqueName);
await page.locator('#subject-code').fill('lowercase');
await page.getByRole('button', { name: /créer la matière/i }).click();
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
// Verify code is displayed in uppercase
const subjectCard = page.locator('.subject-card', { hasText: uniqueName });
await expect(subjectCard.getByText('LOWERCASE')).toBeVisible();
});
test('shows error for duplicate code', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/subjects`);
// Create first subject
const uniqueCode = `DUP${Date.now() % 10000}`;
await openNewSubjectDialog(page);
await page.locator('#subject-name').fill(`First-${Date.now()}`);
await page.locator('#subject-code').fill(uniqueCode);
await page.getByRole('button', { name: /créer la matière/i }).click();
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
// Try to create second subject with same code
await openNewSubjectDialog(page);
await page.locator('#subject-name').fill(`Second-${Date.now()}`);
await page.locator('#subject-code').fill(uniqueCode);
await page.getByRole('button', { name: /créer la matière/i }).click();
// Should show error
await expect(page.getByText(/existe déjà|already exists/i)).toBeVisible({ timeout: 10000 });
});
});
// ============================================================================
// AC2: Subject Modification
// ============================================================================
test.describe('AC2: Subject Modification', () => {
test('can modify an existing subject', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/subjects`);
// First create a subject to modify
await openNewSubjectDialog(page);
const originalName = `ToModify-${Date.now()}`;
const originalCode = `MOD${Date.now() % 10000}`;
await page.locator('#subject-name').fill(originalName);
await page.locator('#subject-code').fill(originalCode);
await page.getByRole('button', { name: /créer la matière/i }).click();
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
// Find the subject card and click modify
const subjectCard = page.locator('.subject-card', { hasText: originalName });
await subjectCard.getByRole('button', { name: /modifier/i }).click();
// Should navigate to edit page
await expect(page).toHaveURL(/\/admin\/subjects\/[\w-]+/);
// Modify the name
const newName = `Modified-${Date.now()}`;
await page.locator('#subject-name').fill(newName);
// Save
await page.getByRole('button', { name: /enregistrer/i }).click();
// Should show success message
await expect(page.getByText(/mise à jour avec succès/i)).toBeVisible({ timeout: 10000 });
// Go back to list and verify
await page.goto(`${ALPHA_URL}/admin/subjects`);
await expect(page.getByText(newName)).toBeVisible();
});
test('can cancel modification', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/subjects`);
// Create a subject
await openNewSubjectDialog(page);
const originalName = `NoChange-${Date.now()}`;
const originalCode = `NCH${Date.now() % 10000}`;
await page.locator('#subject-name').fill(originalName);
await page.locator('#subject-code').fill(originalCode);
await page.getByRole('button', { name: /créer la matière/i }).click();
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
// Click modify
const subjectCard = page.locator('.subject-card', { hasText: originalName });
await subjectCard.getByRole('button', { name: /modifier/i }).click();
// Modify but cancel
await page.locator('#subject-name').fill('Should-Not-Change');
await page.getByRole('button', { name: /annuler/i }).click();
// Should go back to list
await expect(page).toHaveURL(/\/admin\/subjects$/);
// Original name should still be there
await expect(page.getByText(originalName)).toBeVisible();
await expect(page.getByText('Should-Not-Change')).not.toBeVisible();
});
test('can change subject color', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/subjects`);
// Create a subject with blue color
await openNewSubjectDialog(page);
const subjectName = `ColorChange-${Date.now()}`;
const subjectCode = `CLR${Date.now() % 10000}`;
await page.locator('#subject-name').fill(subjectName);
await page.locator('#subject-code').fill(subjectCode);
await page.locator('.color-swatch').first().click(); // Blue
await page.getByRole('button', { name: /créer la matière/i }).click();
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
// Click modify
const subjectCard = page.locator('.subject-card', { hasText: subjectName });
await subjectCard.getByRole('button', { name: /modifier/i }).click();
// Change to red color (second swatch)
await page.locator('.color-swatch').nth(1).click();
// Save
await page.getByRole('button', { name: /enregistrer/i }).click();
await expect(page.getByText(/mise à jour avec succès/i)).toBeVisible({ timeout: 10000 });
});
test('can remove subject color', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/subjects`);
// Create a subject with a color
await openNewSubjectDialog(page);
const subjectName = `RemoveColor-${Date.now()}`;
const subjectCode = `RMC${Date.now() % 10000}`;
await page.locator('#subject-name').fill(subjectName);
await page.locator('#subject-code').fill(subjectCode);
await page.locator('.color-swatch').first().click();
await page.getByRole('button', { name: /créer la matière/i }).click();
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
// Click modify
const subjectCard = page.locator('.subject-card', { hasText: subjectName });
await subjectCard.getByRole('button', { name: /modifier/i }).click();
// Click "no color" button
await page.locator('.color-none').click();
// Save
await page.getByRole('button', { name: /enregistrer/i }).click();
await expect(page.getByText(/mise à jour avec succès/i)).toBeVisible({ timeout: 10000 });
});
});
// ============================================================================
// AC3: Deletion with warning for subjects with grades
// ============================================================================
test.describe('AC3: Deletion with warning for grades', () => {
// SKIP REASON: The Grades module is not yet implemented.
// HasGradesForSubjectHandler currently returns false (stub), so all subjects
// appear without grades and can be deleted without warning. This test will
// be enabled once the Grades module allows recording grades for subjects.
//
// When enabled, this test should:
// 1. Create a subject
// 2. Add at least one grade to it
// 3. Attempt to delete the subject
// 4. Verify the warning message about grades
// 5. Require explicit confirmation
test.skip('shows warning when trying to delete subject with grades', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/subjects`);
// Implementation pending Grades module
});
});
// ============================================================================
// AC4: Subject deletion (soft delete)
// ============================================================================
test.describe('AC4: Subject deletion (soft delete)', () => {
test('can delete a subject without grades', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/subjects`);
// Create a subject to delete
await openNewSubjectDialog(page);
const subjectName = `ToDelete-${Date.now()}`;
const subjectCode = `DEL${Date.now() % 10000}`;
await page.locator('#subject-name').fill(subjectName);
await page.locator('#subject-code').fill(subjectCode);
await page.getByRole('button', { name: /créer la matière/i }).click();
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
await expect(page.getByText(subjectName)).toBeVisible();
// Find and click delete button
const subjectCard = page.locator('.subject-card', { hasText: subjectName });
await subjectCard.getByRole('button', { name: /supprimer/i }).click();
// Confirmation modal should appear
const deleteModal = page.getByRole('alertdialog');
await expect(deleteModal).toBeVisible({ timeout: 10000 });
await expect(deleteModal.getByText(subjectName)).toBeVisible();
// Confirm deletion
await deleteModal.getByRole('button', { name: /supprimer/i }).click();
// Modal should close and subject should no longer appear in list
await expect(deleteModal).not.toBeVisible({ timeout: 10000 });
await expect(page.getByText(subjectName)).not.toBeVisible({ timeout: 10000 });
});
test('can cancel deletion', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/subjects`);
// Create a subject
await openNewSubjectDialog(page);
const subjectName = `NoDelete-${Date.now()}`;
const subjectCode = `NDL${Date.now() % 10000}`;
await page.locator('#subject-name').fill(subjectName);
await page.locator('#subject-code').fill(subjectCode);
await page.getByRole('button', { name: /créer la matière/i }).click();
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
// Find and click delete
const subjectCard = page.locator('.subject-card', { hasText: subjectName });
await subjectCard.getByRole('button', { name: /supprimer/i }).click();
// Confirmation modal should appear
const deleteModal = page.getByRole('alertdialog');
await expect(deleteModal).toBeVisible({ timeout: 10000 });
// Cancel deletion
await deleteModal.getByRole('button', { name: /annuler/i }).click();
// Modal should close and subject should still be there
await expect(deleteModal).not.toBeVisible({ timeout: 10000 });
await expect(page.getByText(subjectName)).toBeVisible();
});
});
// ============================================================================
// Navigation
// ============================================================================
test.describe('Navigation', () => {
test('can access subjects page directly', async ({ page }) => {
await loginAsAdmin(page);
// Navigate directly to subjects page
await page.goto(`${ALPHA_URL}/admin/subjects`);
await expect(page).toHaveURL(/\/admin\/subjects/);
await expect(page.getByRole('heading', { name: /gestion des matières/i })).toBeVisible();
});
test('back button navigation works on edit page', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/subjects`);
// Create a subject
await openNewSubjectDialog(page);
const subjectName = `BackNav-${Date.now()}`;
const subjectCode = `BCK${Date.now() % 10000}`;
await page.locator('#subject-name').fill(subjectName);
await page.locator('#subject-code').fill(subjectCode);
await page.getByRole('button', { name: /créer la matière/i }).click();
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
// Go to edit page
const subjectCard = page.locator('.subject-card', { hasText: subjectName });
await subjectCard.getByRole('button', { name: /modifier/i }).click();
// Click back button to go back
await page.getByRole('button', { name: /retour aux matières/i }).click();
await expect(page).toHaveURL(/\/admin\/subjects$/);
});
});
// ============================================================================
// Validation
// ============================================================================
test.describe('Validation', () => {
test('shows validation for subject name length', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/subjects`);
await openNewSubjectDialog(page);
// Try a name that's too short (1 char)
await page.locator('#subject-name').fill('A');
await page.locator('#subject-code').fill('TEST');
// The HTML5 minlength validation should prevent submission
const nameInput = page.locator('#subject-name');
const isInvalid = await nameInput.evaluate((el: HTMLInputElement) => !el.validity.valid);
expect(isInvalid).toBe(true);
});
test('shows validation for subject code format', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/subjects`);
await openNewSubjectDialog(page);
// Try a code that's too short (1 char)
await page.locator('#subject-name').fill('Test Subject');
await page.locator('#subject-code').fill('A');
// The HTML5 minlength validation should prevent submission
const codeInput = page.locator('#subject-code');
const isInvalid = await codeInput.evaluate((el: HTMLInputElement) => !el.validity.valid);
expect(isInvalid).toBe(true);
});
test('code rejects special characters (backend validation)', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/subjects`);
await openNewSubjectDialog(page);
// Try a code with special characters
await page.locator('#subject-name').fill('Test Subject');
await page.locator('#subject-code').fill('TEST-123');
// Submit - backend should reject it
await page.getByRole('button', { name: /créer la matière/i }).click();
// Should show error from backend
await expect(page.getByText(/alphanumériques|alphanumeric/i)).toBeVisible({ timeout: 10000 });
});
});
// ============================================================================
// Color Picker
// ============================================================================
test.describe('Color Picker', () => {
test('can select predefined colors', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/subjects`);
await openNewSubjectDialog(page);
// Verify all color swatches are visible
const colorSwatches = page.locator('.color-swatch:not(.color-none)');
const count = await colorSwatches.count();
expect(count).toBeGreaterThanOrEqual(7); // At least 7 predefined colors
// Click each color and verify it gets selected
for (let i = 0; i < Math.min(count, 3); i++) {
await colorSwatches.nth(i).click();
await expect(colorSwatches.nth(i)).toHaveClass(/selected/);
}
});
test('can use custom color picker', async ({ page }) => {
await loginAsAdmin(page);
await page.goto(`${ALPHA_URL}/admin/subjects`);
await openNewSubjectDialog(page);
// The color input should be visible
const colorInput = page.locator('input[type="color"]');
await expect(colorInput).toBeVisible();
// Set a custom color
await colorInput.fill('#FF5733');
// Fill required fields and submit
const uniqueName = `CustomColor-${Date.now()}`;
const uniqueCode = `CC${Date.now() % 10000}`;
await page.locator('#subject-name').fill(uniqueName);
await page.locator('#subject-code').fill(uniqueCode);
await page.getByRole('button', { name: /créer la matière/i }).click();
await expect(page.getByRole('dialog')).not.toBeVisible({ timeout: 10000 });
// Subject should be created
await expect(page.getByText(uniqueName)).toBeVisible();
});
});
});

View File

@@ -36,6 +36,11 @@
<span class="action-label">Configurer les classes</span>
<span class="action-hint">Créer et gérer</span>
</a>
<a class="action-card" href="/admin/subjects">
<span class="action-icon">📚</span>
<span class="action-label">Gérer les matières</span>
<span class="action-hint">Créer et gérer</span>
</a>
<div class="action-card disabled" aria-disabled="true">
<span class="action-icon">📅</span>
<span class="action-label">Calendrier scolaire</span>

View File

@@ -0,0 +1,212 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { logout } from '$lib/auth/auth.svelte';
let { children } = $props();
let isLoggingOut = $state(false);
async function handleLogout() {
isLoggingOut = true;
try {
await logout();
} finally {
isLoggingOut = false;
}
}
function goHome() {
goto('/dashboard');
}
function goSettings() {
goto('/settings');
}
// Determine which admin section is active
const isClassesActive = $derived(page.url.pathname.startsWith('/admin/classes'));
const isSubjectsActive = $derived(page.url.pathname.startsWith('/admin/subjects'));
</script>
<div class="admin-layout">
<header class="admin-header">
<div class="header-content">
<button class="logo-button" onclick={goHome}>
<span class="logo-text">Classeo</span>
</button>
<nav class="header-nav">
<a href="/dashboard" class="nav-link">Tableau de bord</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>
<button class="nav-button" onclick={goSettings}>Paramètres</button>
<button class="logout-button" onclick={handleLogout} disabled={isLoggingOut}>
{#if isLoggingOut}
<span class="spinner"></span>
Déconnexion...
{:else}
Déconnexion
{/if}
</button>
</nav>
</div>
</header>
<main class="admin-main">
<div class="main-content">
{@render children()}
</div>
</main>
</div>
<style>
.admin-layout {
min-height: 100vh;
display: flex;
flex-direction: column;
background: var(--surface-primary, #f8fafc);
}
.admin-header {
background: var(--surface-elevated, #fff);
border-bottom: 1px solid var(--border-subtle, #e2e8f0);
padding: 0 1.5rem;
position: sticky;
top: 0;
z-index: 100;
}
.header-content {
max-width: 1200px;
margin: 0 auto;
display: flex;
justify-content: space-between;
align-items: center;
height: 64px;
}
.logo-button {
background: none;
border: none;
cursor: pointer;
padding: 0.5rem 0;
}
.logo-text {
font-size: 1.25rem;
font-weight: 700;
color: var(--accent-primary, #0ea5e9);
}
.header-nav {
display: flex;
align-items: center;
gap: 0.5rem;
}
.nav-link {
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--text-secondary, #64748b);
text-decoration: none;
border-radius: 0.5rem;
transition: all 0.2s;
}
.nav-link:hover {
color: var(--text-primary, #1f2937);
background: var(--surface-primary, #f8fafc);
}
.nav-link.active {
color: var(--accent-primary, #0ea5e9);
background: var(--accent-primary-light, #e0f2fe);
}
.nav-button {
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--text-secondary, #64748b);
background: transparent;
border: none;
border-radius: 0.5rem;
cursor: pointer;
transition: all 0.2s;
}
.nav-button:hover {
color: var(--text-primary, #1f2937);
background: var(--surface-primary, #f8fafc);
}
.logout-button {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--text-secondary, #64748b);
background: transparent;
border: 1px solid var(--border-subtle, #e2e8f0);
border-radius: 0.5rem;
cursor: pointer;
transition: all 0.2s;
}
.logout-button:hover:not(:disabled) {
color: var(--color-alert, #ef4444);
border-color: var(--color-alert, #ef4444);
}
.logout-button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.spinner {
width: 14px;
height: 14px;
border: 2px solid var(--border-subtle, #e2e8f0);
border-top-color: var(--text-secondary, #64748b);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.admin-main {
flex: 1;
padding: 1.5rem;
}
.main-content {
max-width: 1200px;
margin: 0 auto;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@media (max-width: 768px) {
.header-content {
flex-wrap: wrap;
height: auto;
padding: 0.75rem 0;
gap: 0.75rem;
}
.header-nav {
width: 100%;
justify-content: flex-end;
flex-wrap: wrap;
gap: 0.5rem;
}
.admin-main {
padding: 1rem;
}
}
</style>

View File

@@ -0,0 +1,842 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { getApiBaseUrl } from '$lib/api/config';
import { authenticatedFetch } from '$lib/auth';
// Types
interface Subject {
id: string;
name: string;
code: string;
color: string | null;
description: string | null;
status: string;
teacherCount: number | null;
classCount: number | null;
createdAt: string;
updatedAt: string;
}
// Couleurs prédéfinies (suggestions UI)
const SUGGESTED_COLORS = [
{ label: 'Bleu (Mathématiques)', value: '#3B82F6' },
{ label: 'Rouge (Français)', value: '#EF4444' },
{ label: 'Orange (Histoire-Géo)', value: '#F59E0B' },
{ label: 'Vert (Sciences)', value: '#10B981' },
{ label: 'Indigo (Anglais)', value: '#6366F1' },
{ label: 'Rose (EPS)', value: '#EC4899' },
{ label: 'Violet (Arts)', value: '#8B5CF6' },
{ label: 'Gris (Autre)', value: '#6B7280' }
];
// State
let subjects = $state<Subject[]>([]);
let isLoading = $state(true);
let error = $state<string | null>(null);
let showCreateModal = $state(false);
let showDeleteModal = $state(false);
let subjectToDelete = $state<Subject | null>(null);
// Form state
let newSubjectName = $state('');
let newSubjectCode = $state('');
let newSubjectColor = $state<string | null>(null);
let isSubmitting = $state(false);
let isDeleting = $state(false);
// Load subjects on mount
$effect(() => {
loadSubjects();
});
async function loadSubjects() {
try {
isLoading = true;
error = null;
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(`${apiUrl}/subjects`);
if (!response.ok) {
throw new Error('Erreur lors du chargement des matières');
}
const data = await response.json();
// API Platform peut retourner hydra:member, member, ou un tableau direct
subjects = data['hydra:member'] ?? data['member'] ?? (Array.isArray(data) ? data : []);
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur inconnue';
subjects = [];
} finally {
isLoading = false;
}
}
async function handleCreateSubject() {
if (!newSubjectName.trim() || !newSubjectCode.trim()) return;
try {
isSubmitting = true;
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(`${apiUrl}/subjects`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: newSubjectName.trim(),
code: newSubjectCode.trim().toUpperCase(),
color: newSubjectColor
})
});
if (!response.ok) {
let errorMessage = `Erreur lors de la création (${response.status})`;
try {
const errorData = await response.json();
if (errorData['hydra:description']) {
errorMessage = errorData['hydra:description'];
} else if (errorData.message) {
errorMessage = errorData.message;
} else if (errorData.detail) {
errorMessage = errorData.detail;
}
} catch {
// JSON parsing failed, keep default message
}
throw new Error(errorMessage);
}
// Reload subjects and close modal
await loadSubjects();
closeModal();
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur lors de la création';
} finally {
isSubmitting = false;
}
}
function openDeleteModal(subject: Subject) {
subjectToDelete = subject;
showDeleteModal = true;
}
function closeDeleteModal() {
showDeleteModal = false;
subjectToDelete = null;
}
async function handleConfirmDelete() {
if (!subjectToDelete) return;
try {
isDeleting = true;
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(`${apiUrl}/subjects/${subjectToDelete.id}`, {
method: 'DELETE'
});
if (!response.ok) {
let errorMessage = `Erreur lors de la suppression (${response.status})`;
try {
const errorData = await response.json();
if (errorData['hydra:description']) {
errorMessage = errorData['hydra:description'];
} else if (errorData.message) {
errorMessage = errorData.message;
} else if (errorData.detail) {
errorMessage = errorData.detail;
}
} catch {
// JSON parsing failed, keep default message
}
throw new Error(errorMessage);
}
closeDeleteModal();
await loadSubjects();
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur lors de la suppression';
} finally {
isDeleting = false;
}
}
function openCreateModal() {
showCreateModal = true;
newSubjectName = '';
newSubjectCode = '';
newSubjectColor = null;
}
function closeModal() {
showCreateModal = false;
}
function navigateToEdit(subjectId: string) {
goto(`/admin/subjects/${subjectId}`);
}
function getColorStyle(color: string | null): string {
if (!color) return '';
return `background-color: ${color}; color: ${getContrastColor(color)}`;
}
function getContrastColor(hexColor: string): string {
// Simple luminance check for contrast
const r = parseInt(hexColor.slice(1, 3), 16);
const g = parseInt(hexColor.slice(3, 5), 16);
const b = parseInt(hexColor.slice(5, 7), 16);
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return luminance > 0.5 ? '#000000' : '#FFFFFF';
}
</script>
<svelte:head>
<title>Gestion des matières - Classeo</title>
</svelte:head>
<div class="subjects-page">
<header class="page-header">
<div class="header-content">
<h1>Gestion des matières</h1>
<p class="subtitle">Créez et gérez les matières enseignées dans votre établissement</p>
</div>
<button class="btn-primary" onclick={openCreateModal}>
<span class="btn-icon">+</span>
Nouvelle matière
</button>
</header>
{#if error}
<div class="alert alert-error">
<span class="alert-icon">⚠️</span>
{error}
<button class="alert-close" onclick={() => (error = null)}>×</button>
</div>
{/if}
{#if isLoading}
<div class="loading-state">
<div class="spinner"></div>
<p>Chargement des matières...</p>
</div>
{:else if subjects.length === 0}
<div class="empty-state">
<span class="empty-icon">📚</span>
<h2>Aucune matière</h2>
<p>Commencez par créer votre première matière</p>
<button class="btn-primary" onclick={openCreateModal}>Créer une matière</button>
</div>
{:else}
<div class="subjects-grid">
{#each subjects as subject (subject.id)}
<div class="subject-card">
<div class="subject-header">
{#if subject.color}
<span class="subject-color-badge" style={getColorStyle(subject.color)}>
{subject.code}
</span>
{:else}
<span class="subject-code">{subject.code}</span>
{/if}
<h3 class="subject-name">{subject.name}</h3>
</div>
<div class="subject-stats">
<span class="stat-item" title="Enseignants affectés">
<span class="stat-icon">👨‍🏫</span>
{subject.teacherCount ?? 0}
</span>
<span class="stat-item" title="Classes associées">
<span class="stat-icon">🏫</span>
{subject.classCount ?? 0}
</span>
<span class="stat-item status-{subject.status}">
{subject.status === 'active' ? 'Active' : 'Archivée'}
</span>
</div>
<div class="subject-actions">
<button class="btn-secondary btn-sm" onclick={() => navigateToEdit(subject.id)}>
Modifier
</button>
<button class="btn-danger btn-sm" onclick={() => openDeleteModal(subject)}>
Supprimer
</button>
</div>
</div>
{/each}
</div>
{/if}
</div>
<!-- Create Modal -->
{#if showCreateModal}
<div class="modal-overlay" onclick={closeModal} role="presentation">
<div
class="modal"
onclick={(e) => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-labelledby="modal-title"
>
<header class="modal-header">
<h2 id="modal-title">Nouvelle matière</h2>
<button class="modal-close" onclick={closeModal} aria-label="Fermer">×</button>
</header>
<form
class="modal-body"
onsubmit={(e) => {
e.preventDefault();
handleCreateSubject();
}}
>
<div class="form-group">
<label for="subject-name">Nom de la matière *</label>
<input
type="text"
id="subject-name"
bind:value={newSubjectName}
placeholder="ex: Mathématiques"
required
minlength="2"
maxlength="100"
/>
</div>
<div class="form-group">
<label for="subject-code">Code court *</label>
<input
type="text"
id="subject-code"
bind:value={newSubjectCode}
placeholder="ex: MATH"
required
minlength="2"
maxlength="10"
/>
<small class="form-hint">2 à 10 caractères (lettres et chiffres uniquement)</small>
</div>
<div class="form-group">
<label for="subject-color">Couleur</label>
<div class="color-picker-group">
<div class="color-swatches">
{#each SUGGESTED_COLORS as colorOption}
<button
type="button"
class="color-swatch"
class:selected={newSubjectColor === colorOption.value}
style="background-color: {colorOption.value}"
onclick={() => (newSubjectColor = colorOption.value)}
title={colorOption.label}
></button>
{/each}
<button
type="button"
class="color-swatch color-none"
class:selected={newSubjectColor === null}
onclick={() => (newSubjectColor = null)}
title="Aucune couleur"
>
</button>
</div>
<input
type="color"
id="subject-color"
value={newSubjectColor ?? '#6B7280'}
onchange={(e) => (newSubjectColor = e.currentTarget.value)}
/>
</div>
</div>
<div class="modal-actions">
<button type="button" class="btn-secondary" onclick={closeModal} disabled={isSubmitting}>
Annuler
</button>
<button
type="submit"
class="btn-primary"
disabled={isSubmitting || !newSubjectName.trim() || !newSubjectCode.trim()}
>
{#if isSubmitting}
Création...
{:else}
Créer la matière
{/if}
</button>
</div>
</form>
</div>
</div>
{/if}
<!-- Delete Confirmation Modal -->
{#if showDeleteModal && subjectToDelete}
<div class="modal-overlay" onclick={closeDeleteModal} role="presentation">
<div
class="modal modal-confirm"
onclick={(e) => e.stopPropagation()}
role="alertdialog"
aria-modal="true"
aria-labelledby="delete-modal-title"
aria-describedby="delete-modal-description"
>
<header class="modal-header modal-header-danger">
<h2 id="delete-modal-title">Supprimer la matière</h2>
<button class="modal-close" onclick={closeDeleteModal} aria-label="Fermer">×</button>
</header>
<div class="modal-body">
<p id="delete-modal-description">
Êtes-vous sûr de vouloir supprimer la matière <strong>{subjectToDelete.name}</strong> ({subjectToDelete.code})
?
</p>
<p class="delete-warning">Cette action est irréversible.</p>
</div>
<div class="modal-actions">
<button
type="button"
class="btn-secondary"
onclick={closeDeleteModal}
disabled={isDeleting}
>
Annuler
</button>
<button type="button" class="btn-danger" onclick={handleConfirmDelete} disabled={isDeleting}>
{#if isDeleting}
Suppression...
{:else}
Supprimer
{/if}
</button>
</div>
</div>
</div>
{/if}
<style>
.subjects-page {
padding: 1.5rem;
max-width: 1200px;
margin: 0 auto;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.header-content h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
color: #1f2937;
}
.subtitle {
margin: 0.25rem 0 0;
color: #6b7280;
font-size: 0.875rem;
}
.btn-primary {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.25rem;
background: #3b82f6;
color: white;
border: none;
border-radius: 0.5rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.btn-primary:hover:not(:disabled) {
background: #2563eb;
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-secondary {
padding: 0.5rem 1rem;
background: white;
color: #374151;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.btn-secondary:hover:not(:disabled) {
background: #f3f4f6;
}
.btn-danger {
padding: 0.5rem 1rem;
background: #fef2f2;
color: #dc2626;
border: 1px solid #fecaca;
border-radius: 0.375rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.btn-danger:hover {
background: #fee2e2;
}
.btn-sm {
padding: 0.375rem 0.75rem;
font-size: 0.875rem;
}
.btn-icon {
font-size: 1.25rem;
line-height: 1;
}
.alert {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem;
border-radius: 0.5rem;
margin-bottom: 1rem;
}
.alert-error {
background: #fef2f2;
border: 1px solid #fecaca;
color: #dc2626;
}
.alert-icon {
flex-shrink: 0;
}
.alert-close {
margin-left: auto;
padding: 0.25rem 0.5rem;
background: none;
border: none;
font-size: 1.25rem;
cursor: pointer;
opacity: 0.6;
}
.alert-close:hover {
opacity: 1;
}
.loading-state,
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
text-align: center;
background: white;
border-radius: 0.75rem;
border: 2px dashed #e5e7eb;
}
.spinner {
width: 2rem;
height: 2rem;
border: 3px solid #e5e7eb;
border-top-color: #3b82f6;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.empty-icon {
font-size: 3rem;
margin-bottom: 1rem;
}
.empty-state h2 {
margin: 0 0 0.5rem;
font-size: 1.25rem;
color: #1f2937;
}
.empty-state p {
margin: 0 0 1.5rem;
color: #6b7280;
}
.subjects-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
.subject-card {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1.25rem;
background: white;
border: 1px solid #e5e7eb;
border-radius: 0.75rem;
transition: box-shadow 0.2s;
}
.subject-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.subject-header {
display: flex;
align-items: center;
gap: 0.75rem;
}
.subject-name {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: #1f2937;
}
.subject-code {
padding: 0.25rem 0.5rem;
background: #f3f4f6;
color: #374151;
border-radius: 0.25rem;
font-size: 0.75rem;
font-weight: 600;
font-family: monospace;
}
.subject-color-badge {
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.75rem;
font-weight: 600;
font-family: monospace;
}
.subject-stats {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.stat-item {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.875rem;
color: #6b7280;
}
.stat-icon {
font-size: 0.875rem;
}
.status-active {
color: #059669;
}
.status-archived {
color: #6b7280;
}
.subject-actions {
display: flex;
gap: 0.5rem;
margin-top: auto;
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
z-index: 100;
}
.modal {
background: white;
border-radius: 0.75rem;
width: 100%;
max-width: 28rem;
max-height: 90vh;
overflow: auto;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid #e5e7eb;
}
.modal-header h2 {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: #1f2937;
}
.modal-close {
padding: 0.25rem 0.5rem;
background: none;
border: none;
font-size: 1.5rem;
line-height: 1;
color: #6b7280;
cursor: pointer;
}
.modal-close:hover {
color: #1f2937;
}
.modal-body {
padding: 1.5rem;
}
.form-group {
margin-bottom: 1.25rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: #374151;
}
.form-group input[type='text'],
.form-group select {
width: 100%;
padding: 0.75rem 1rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 1rem;
transition: border-color 0.2s;
}
.form-group input:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.form-hint {
display: block;
margin-top: 0.25rem;
font-size: 0.75rem;
color: #6b7280;
}
.color-picker-group {
display: flex;
align-items: center;
gap: 1rem;
}
.color-swatches {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.color-swatch {
width: 2rem;
height: 2rem;
border: 2px solid transparent;
border-radius: 0.375rem;
cursor: pointer;
transition: all 0.2s;
}
.color-swatch:hover {
transform: scale(1.1);
}
.color-swatch.selected {
border-color: #1f2937;
box-shadow: 0 0 0 2px white, 0 0 0 4px #3b82f6;
}
.color-none {
background: #f3f4f6;
color: #6b7280;
font-size: 0.875rem;
font-weight: bold;
}
input[type='color'] {
width: 3rem;
height: 2rem;
padding: 0;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
cursor: pointer;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding-top: 1rem;
border-top: 1px solid #e5e7eb;
}
/* Delete confirmation modal */
.modal-confirm {
max-width: 24rem;
}
.modal-confirm .modal-actions {
padding: 1rem 1.5rem;
border-top: 1px solid #e5e7eb;
}
.modal-header-danger {
background: #fef2f2;
border-bottom-color: #fecaca;
}
.modal-header-danger h2 {
color: #dc2626;
}
.delete-warning {
margin: 0.75rem 0 0;
font-size: 0.875rem;
color: #6b7280;
}
</style>

View File

@@ -0,0 +1,515 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { getApiBaseUrl } from '$lib/api/config';
import { authenticatedFetch } from '$lib/auth';
// Types
interface Subject {
id: string;
name: string;
code: string;
color: string | null;
description: string | null;
status: string;
createdAt: string;
updatedAt: string;
}
// Couleurs prédéfinies
const SUGGESTED_COLORS = [
{ label: 'Bleu (Mathématiques)', value: '#3B82F6' },
{ label: 'Rouge (Français)', value: '#EF4444' },
{ label: 'Orange (Histoire-Géo)', value: '#F59E0B' },
{ label: 'Vert (Sciences)', value: '#10B981' },
{ label: 'Indigo (Anglais)', value: '#6366F1' },
{ label: 'Rose (EPS)', value: '#EC4899' },
{ label: 'Violet (Arts)', value: '#8B5CF6' },
{ label: 'Gris (Autre)', value: '#6B7280' }
];
// State
let subject = $state<Subject | null>(null);
let isLoading = $state(true);
let isSaving = $state(false);
let error = $state<string | null>(null);
let successMessage = $state<string | null>(null);
// Form state
let name = $state('');
let code = $state('');
let color = $state<string | null>(null);
let description = $state('');
// Load subject on mount
$effect(() => {
const subjectId = page.params.id;
if (subjectId) {
loadSubject(subjectId);
}
});
async function loadSubject(subjectId: string) {
try {
isLoading = true;
error = null;
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(`${apiUrl}/subjects/${subjectId}`);
if (!response.ok) {
if (response.status === 404) {
throw new Error('Matière non trouvée');
}
throw new Error('Erreur lors du chargement de la matière');
}
const data = await response.json();
subject = data;
// Initialize form state
name = data.name ?? '';
code = data.code ?? '';
color = data.color;
description = data.description ?? '';
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur inconnue';
} finally {
isLoading = false;
}
}
async function handleSave() {
if (!subject) return;
try {
isSaving = true;
error = null;
successMessage = null;
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(`${apiUrl}/subjects/${subject.id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/merge-patch+json'
},
body: JSON.stringify({
name: name.trim(),
code: code.trim().toUpperCase(),
color: color,
description: description.trim() || null,
clearColor: color === null && subject.color !== null,
clearDescription: !description.trim() && subject.description !== null
})
});
if (!response.ok) {
let errorMessage = `Erreur lors de la sauvegarde (${response.status})`;
try {
const errorData = await response.json();
if (errorData['hydra:description']) {
errorMessage = errorData['hydra:description'];
} else if (errorData.message) {
errorMessage = errorData.message;
} else if (errorData.detail) {
errorMessage = errorData.detail;
}
} catch {
// JSON parsing failed, keep default message
}
throw new Error(errorMessage);
}
const updatedSubject = await response.json();
subject = updatedSubject;
successMessage = 'Matière mise à jour avec succès';
// Clear success message after 3 seconds
window.setTimeout(() => {
successMessage = null;
}, 3000);
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur lors de la sauvegarde';
} finally {
isSaving = false;
}
}
function goBack() {
goto('/admin/subjects');
}
</script>
<svelte:head>
<title>{subject?.name ?? 'Chargement...'} - Gestion des matières - Classeo</title>
</svelte:head>
<div class="edit-page">
<header class="page-header">
<button class="btn-back" onclick={goBack}>
← Retour aux matières
</button>
<h1>{subject?.name ?? 'Chargement...'}</h1>
</header>
{#if error}
<div class="alert alert-error">
<span class="alert-icon">⚠️</span>
{error}
<button class="alert-close" onclick={() => (error = null)}>×</button>
</div>
{/if}
{#if successMessage}
<div class="alert alert-success">
<span class="alert-icon"></span>
{successMessage}
</div>
{/if}
{#if isLoading}
<div class="loading-state">
<div class="spinner"></div>
<p>Chargement de la matière...</p>
</div>
{:else if subject}
<form
class="edit-form"
onsubmit={(e) => {
e.preventDefault();
handleSave();
}}
>
<div class="form-card">
<h2>Informations générales</h2>
<div class="form-group">
<label for="subject-name">Nom de la matière *</label>
<input
type="text"
id="subject-name"
bind:value={name}
placeholder="ex: Mathématiques"
required
minlength="2"
maxlength="100"
/>
</div>
<div class="form-group">
<label for="subject-code">Code court *</label>
<input
type="text"
id="subject-code"
bind:value={code}
placeholder="ex: MATH"
required
minlength="2"
maxlength="10"
/>
<small class="form-hint">2 à 10 caractères (lettres et chiffres uniquement)</small>
</div>
<div class="form-group">
<label for="subject-color">Couleur</label>
<div class="color-picker-group">
<div class="color-swatches">
{#each SUGGESTED_COLORS as colorOption}
<button
type="button"
class="color-swatch"
class:selected={color === colorOption.value}
style="background-color: {colorOption.value}"
onclick={() => (color = colorOption.value)}
title={colorOption.label}
></button>
{/each}
<button
type="button"
class="color-swatch color-none"
class:selected={color === null}
onclick={() => (color = null)}
title="Aucune couleur"
>
</button>
</div>
<input
type="color"
id="subject-color"
value={color ?? '#6B7280'}
onchange={(e) => (color = e.currentTarget.value)}
/>
</div>
</div>
<div class="form-group">
<label for="subject-description">Description</label>
<textarea
id="subject-description"
bind:value={description}
placeholder="Description optionnelle de la matière..."
rows="3"
></textarea>
</div>
</div>
<div class="form-actions">
<button type="button" class="btn-secondary" onclick={goBack}>Annuler</button>
<button
type="submit"
class="btn-primary"
disabled={isSaving || !name.trim() || !code.trim()}
>
{#if isSaving}
Enregistrement...
{:else}
Enregistrer les modifications
{/if}
</button>
</div>
</form>
{:else}
<div class="empty-state">
<p>Matière non trouvée</p>
<button class="btn-primary" onclick={goBack}>Retour à la liste</button>
</div>
{/if}
</div>
<style>
.edit-page {
padding: 1.5rem;
max-width: 800px;
margin: 0 auto;
}
.page-header {
margin-bottom: 1.5rem;
}
.btn-back {
padding: 0.5rem 0;
background: none;
border: none;
color: #3b82f6;
font-weight: 500;
cursor: pointer;
margin-bottom: 0.5rem;
}
.btn-back:hover {
color: #2563eb;
}
.page-header h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
color: #1f2937;
}
.alert {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 1rem;
border-radius: 0.5rem;
margin-bottom: 1rem;
}
.alert-error {
background: #fef2f2;
border: 1px solid #fecaca;
color: #dc2626;
}
.alert-success {
background: #f0fdf4;
border: 1px solid #bbf7d0;
color: #16a34a;
}
.alert-icon {
flex-shrink: 0;
}
.alert-close {
margin-left: auto;
padding: 0.25rem 0.5rem;
background: none;
border: none;
font-size: 1.25rem;
cursor: pointer;
opacity: 0.6;
}
.loading-state,
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
text-align: center;
background: white;
border-radius: 0.75rem;
border: 2px dashed #e5e7eb;
}
.spinner {
width: 2rem;
height: 2rem;
border: 3px solid #e5e7eb;
border-top-color: #3b82f6;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.form-card {
background: white;
border: 1px solid #e5e7eb;
border-radius: 0.75rem;
padding: 1.5rem;
margin-bottom: 1.5rem;
}
.form-card h2 {
margin: 0 0 1.5rem;
font-size: 1.125rem;
font-weight: 600;
color: #1f2937;
}
.form-group {
margin-bottom: 1.25rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: #374151;
}
.form-group input[type='text'],
.form-group textarea {
width: 100%;
padding: 0.75rem 1rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
font-size: 1rem;
transition: border-color 0.2s;
}
.form-group input:focus,
.form-group textarea:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.form-group textarea {
resize: vertical;
min-height: 80px;
}
.form-hint {
display: block;
margin-top: 0.25rem;
font-size: 0.75rem;
color: #6b7280;
}
.color-picker-group {
display: flex;
align-items: center;
gap: 1rem;
}
.color-swatches {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.color-swatch {
width: 2rem;
height: 2rem;
border: 2px solid transparent;
border-radius: 0.375rem;
cursor: pointer;
transition: all 0.2s;
}
.color-swatch:hover {
transform: scale(1.1);
}
.color-swatch.selected {
border-color: #1f2937;
box-shadow: 0 0 0 2px white, 0 0 0 4px #3b82f6;
}
.color-none {
background: #f3f4f6;
color: #6b7280;
font-size: 0.875rem;
font-weight: bold;
}
input[type='color'] {
width: 3rem;
height: 2rem;
padding: 0;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
cursor: pointer;
}
.form-actions {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
}
.btn-primary {
padding: 0.75rem 1.25rem;
background: #3b82f6;
color: white;
border: none;
border-radius: 0.5rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.btn-primary:hover:not(:disabled) {
background: #2563eb;
}
.btn-primary:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.btn-secondary {
padding: 0.75rem 1.25rem;
background: white;
color: #374151;
border: 1px solid #d1d5db;
border-radius: 0.5rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s;
}
.btn-secondary:hover {
background: #f3f4f6;
}
</style>