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

@@ -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>