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:
842
frontend/src/routes/admin/subjects/+page.svelte
Normal file
842
frontend/src/routes/admin/subjects/+page.svelte
Normal 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>
|
||||
515
frontend/src/routes/admin/subjects/[id]/+page.svelte
Normal file
515
frontend/src/routes/admin/subjects/[id]/+page.svelte
Normal 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>
|
||||
Reference in New Issue
Block a user