Files
Classeo/frontend/src/routes/admin/subjects/[id]/+page.svelte
Mathias STRASSER 88e7f319db feat: Affectation des enseignants aux classes et matières
Permet aux administrateurs d'associer un enseignant à une classe pour une
matière donnée au sein d'une année scolaire. Cette brique est nécessaire
pour construire les emplois du temps et les carnets de notes par la suite.

Le modèle impose l'unicité du triplet enseignant × classe × matière par
année scolaire, avec réactivation automatique d'une affectation retirée
plutôt que duplication. L'isolation multi-tenant est garantie au niveau
du repository (findById/get filtrent par tenant_id).
2026-02-13 20:22:39 +01:00

516 lines
10 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 { 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
globalThis.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>