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).
516 lines
10 KiB
Svelte
516 lines
10 KiB
Svelte
<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>
|