Files
Classeo/frontend/src/routes/admin/subjects/+page.svelte
Mathias STRASSER 0d5a097c4c 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.
2026-02-05 20:42:31 +01:00

843 lines
18 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>