Files
Classeo/frontend/src/routes/dashboard/teacher/evaluations/+page.svelte
Mathias STRASSER b70d5ec2ad
Some checks failed
CI / Backend Tests (push) Has been cancelled
CI / Frontend Tests (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
CI / Naming Conventions (push) Has been cancelled
CI / Build Check (push) Has been cancelled
feat: Permettre à l'enseignant de saisir les notes dans une grille inline
L'enseignant avait besoin d'un moyen rapide de saisir les notes après
une évaluation. La grille inline permet de compléter 30 élèves en moins
de 3 minutes grâce à la navigation clavier (Tab/Enter/Shift+Tab),
la validation temps réel, l'auto-save debounced (500ms) et les
raccourcis /abs et /disp pour marquer absents/dispensés.

Les notes restent en brouillon jusqu'à publication explicite (avec
confirmation modale). Une fois publiées, les élèves les voient
immédiatement ; les parents après un délai de 24h (VisibiliteNotesPolicy).
Le mode offline stocke les notes en IndexedDB et synchronise
automatiquement au retour de la connexion.

Chaque modification est auditée dans grade_events via un event
subscriber qui écoute NoteSaisie/NoteModifiee sur le bus d'événements.
2026-03-29 10:02:03 +02:00

1180 lines
27 KiB
Svelte

<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { getApiBaseUrl } from '$lib/api/config';
import { authenticatedFetch, getAuthenticatedUserId } from '$lib/auth';
import Pagination from '$lib/components/molecules/Pagination/Pagination.svelte';
import SearchInput from '$lib/components/molecules/SearchInput/SearchInput.svelte';
import { untrack } from 'svelte';
interface Evaluation {
id: string;
classId: string;
subjectId: string;
teacherId: string;
title: string;
description: string | null;
evaluationDate: string;
gradeScale: number;
coefficient: number;
status: string;
className: string | null;
subjectName: string | null;
createdAt: string;
updatedAt: string;
}
interface TeacherAssignment {
id: string;
classId: string;
subjectId: string;
status: string;
}
interface SchoolClass {
id: string;
name: string;
}
interface Subject {
id: string;
name: string;
code: string;
}
// State
let evaluations = $state<Evaluation[]>([]);
let assignments = $state<TeacherAssignment[]>([]);
let classes = $state<SchoolClass[]>([]);
let subjects = $state<Subject[]>([]);
let isLoading = $state(true);
let error = $state<string | null>(null);
// Pagination & Search
let currentPage = $state(Number(page.url.searchParams.get('page')) || 1);
let searchTerm = $state(page.url.searchParams.get('search') ?? '');
let totalItems = $state(0);
const itemsPerPage = 30;
let totalPages = $derived(Math.ceil(totalItems / itemsPerPage));
// Create modal
let showCreateModal = $state(false);
let newClassId = $state('');
let newSubjectId = $state('');
let newTitle = $state('');
let newDescription = $state('');
let newEvaluationDate = $state('');
let newGradeScale = $state(20);
let newCoefficient = $state(1.0);
let isSubmitting = $state(false);
// Edit modal
let showEditModal = $state(false);
let editEvaluation = $state<Evaluation | null>(null);
let editTitle = $state('');
let editDescription = $state('');
let editEvaluationDate = $state('');
let editGradeScale = $state(20);
let editCoefficient = $state(1.0);
let isUpdating = $state(false);
// Delete modal
let showDeleteModal = $state(false);
let evaluationToDelete = $state<Evaluation | null>(null);
let isDeleting = $state(false);
// Class filter
let filterClassId = $state(page.url.searchParams.get('classId') ?? '');
// Derived: available subjects for selected class
let availableSubjectsForCreate = $derived.by(() => {
if (!newClassId) return [];
const assignedSubjectIds = assignments
.filter((a) => a.classId === newClassId && a.status === 'active')
.map((a) => a.subjectId);
return subjects.filter((s) => assignedSubjectIds.includes(s.id));
});
// Derived: available classes
let availableClasses = $derived.by(() => {
const assignedClassIds = [...new Set(assignments.filter((a) => a.status === 'active').map((a) => a.classId))];
return classes.filter((c) => assignedClassIds.includes(c.id));
});
// Derived: grade scale equivalence preview
let gradeScalePreview = $derived.by(() => {
if (newGradeScale === 20) return '';
return `(10/${newGradeScale} = ${((10 / newGradeScale) * 20).toFixed(1)}/20)`;
});
// Load on mount
$effect(() => {
untrack(() => loadAll());
});
function extractCollection<T>(data: Record<string, unknown>): T[] {
const members = data['hydra:member'] ?? data['member'];
if (Array.isArray(members)) return members as T[];
if (Array.isArray(data)) return data as T[];
return [];
}
async function loadAssignments() {
const userId = await getAuthenticatedUserId();
if (!userId) return;
const apiUrl = getApiBaseUrl();
const res = await authenticatedFetch(`${apiUrl}/teachers/${userId}/assignments`);
if (!res.ok) throw new Error('Erreur lors du chargement des affectations');
const data = await res.json();
assignments = extractCollection<TeacherAssignment>(data);
}
async function loadAll() {
try {
isLoading = true;
error = null;
const apiUrl = getApiBaseUrl();
const [classesRes, subjectsRes] = await Promise.all([
authenticatedFetch(`${apiUrl}/classes?itemsPerPage=100`),
authenticatedFetch(`${apiUrl}/subjects?itemsPerPage=100`),
loadAssignments().catch((e) => {
error = e instanceof Error ? e.message : 'Erreur lors du chargement des affectations';
}),
loadEvaluations().catch((e) => {
evaluations = [];
totalItems = 0;
error = e instanceof Error ? e.message : 'Erreur inconnue';
}),
]);
if (!classesRes.ok) throw new Error('Erreur lors du chargement des classes');
if (!subjectsRes.ok) throw new Error('Erreur lors du chargement des matières');
const [classesData, subjectsData] = await Promise.all([
classesRes.json(),
subjectsRes.json(),
]);
classes = extractCollection<SchoolClass>(classesData);
subjects = extractCollection<Subject>(subjectsData);
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur inconnue';
} finally {
isLoading = false;
}
}
let loadAbortController: AbortController | null = null;
async function loadEvaluations() {
loadAbortController?.abort();
const controller = new AbortController();
loadAbortController = controller;
try {
const apiUrl = getApiBaseUrl();
const params = new URLSearchParams();
params.set('page', String(currentPage));
params.set('itemsPerPage', String(itemsPerPage));
if (searchTerm) params.set('search', searchTerm);
if (filterClassId) params.set('classId', filterClassId);
const response = await authenticatedFetch(`${apiUrl}/evaluations?${params.toString()}`, {
signal: controller.signal,
});
if (controller.signal.aborted) return;
if (!response.ok) {
throw new Error('Erreur lors du chargement des évaluations');
}
const data = await response.json();
evaluations = extractCollection<Evaluation>(data);
totalItems = (data as Record<string, unknown>)['hydra:totalItems'] as number ?? (data as Record<string, unknown>)['totalItems'] as number ?? evaluations.length;
} catch (e) {
if (e instanceof DOMException && e.name === 'AbortError') return;
throw e;
}
}
function updateUrl() {
const params = new URLSearchParams();
if (currentPage > 1) params.set('page', String(currentPage));
if (searchTerm) params.set('search', searchTerm);
if (filterClassId) params.set('classId', filterClassId);
const query = params.toString();
goto(`?${query}`, { replaceState: true, noScroll: true, keepFocus: true });
}
function handleSearch(value: string) {
searchTerm = value;
currentPage = 1;
updateUrl();
loadEvaluations().catch((e) => {
error = e instanceof Error ? e.message : 'Erreur inconnue';
});
}
function handlePageChange(newPage: number) {
currentPage = newPage;
updateUrl();
loadEvaluations().catch((e) => {
error = e instanceof Error ? e.message : 'Erreur inconnue';
});
}
function handleClassFilter(newClassId: string) {
filterClassId = newClassId;
currentPage = 1;
updateUrl();
loadEvaluations().catch((e) => {
error = e instanceof Error ? e.message : 'Erreur inconnue';
});
}
function getClassName(classId: string): string {
return classes.find((c) => c.id === classId)?.name ?? classId;
}
function getSubjectName(subjectId: string): string {
return subjects.find((s) => s.id === subjectId)?.name ?? subjectId;
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString('fr-FR', {
day: 'numeric',
month: 'long',
year: 'numeric',
});
}
function formatGradeScale(scale: number): string {
return `/${scale}`;
}
// --- Create ---
function openCreateModal() {
showCreateModal = true;
newClassId = '';
newSubjectId = '';
newTitle = '';
newDescription = '';
newEvaluationDate = '';
newGradeScale = 20;
newCoefficient = 1.0;
}
function closeCreateModal() {
showCreateModal = false;
}
async function handleCreate() {
if (!newClassId || !newSubjectId || !newTitle.trim() || !newEvaluationDate) return;
try {
isSubmitting = true;
error = null;
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(`${apiUrl}/evaluations`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
classId: newClassId,
subjectId: newSubjectId,
title: newTitle.trim(),
description: newDescription.trim() || null,
evaluationDate: newEvaluationDate,
gradeScale: newGradeScale,
coefficient: newCoefficient,
}),
});
if (!response.ok) {
const data = await response.json().catch(() => null);
throw new Error((data as Record<string, string> | null)?.['message'] ?? 'Erreur lors de la création');
}
closeCreateModal();
await loadEvaluations();
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur inconnue';
} finally {
isSubmitting = false;
}
}
// --- Edit ---
function openEditModal(evaluation: Evaluation) {
editEvaluation = evaluation;
editTitle = evaluation.title;
editDescription = evaluation.description ?? '';
editEvaluationDate = evaluation.evaluationDate;
editGradeScale = evaluation.gradeScale;
editCoefficient = evaluation.coefficient;
showEditModal = true;
}
function closeEditModal() {
showEditModal = false;
editEvaluation = null;
}
async function handleUpdate() {
if (!editEvaluation || !editTitle.trim() || !editEvaluationDate) return;
try {
isUpdating = true;
error = null;
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(`${apiUrl}/evaluations/${editEvaluation.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/merge-patch+json' },
body: JSON.stringify({
title: editTitle.trim(),
description: editDescription.trim() || null,
evaluationDate: editEvaluationDate,
gradeScale: editGradeScale,
coefficient: editCoefficient,
}),
});
if (!response.ok) {
const data = await response.json().catch(() => null);
throw new Error((data as Record<string, string> | null)?.['message'] ?? 'Erreur lors de la modification');
}
closeEditModal();
await loadEvaluations();
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur inconnue';
} finally {
isUpdating = false;
}
}
// --- Delete ---
function openDeleteModal(evaluation: Evaluation) {
evaluationToDelete = evaluation;
showDeleteModal = true;
}
function closeDeleteModal() {
showDeleteModal = false;
evaluationToDelete = null;
}
async function handleDelete() {
if (!evaluationToDelete) return;
try {
isDeleting = true;
error = null;
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(`${apiUrl}/evaluations/${evaluationToDelete.id}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error('Erreur lors de la suppression');
}
closeDeleteModal();
await loadEvaluations();
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur inconnue';
} finally {
isDeleting = false;
}
}
</script>
<svelte:head>
<title>Mes évaluations - Classeo</title>
</svelte:head>
<div class="evaluations-page">
<header class="page-header">
<div class="header-content">
<h1>Mes évaluations</h1>
<p class="subtitle">Créez et gérez les évaluations pour vos classes</p>
</div>
<button class="btn-primary" onclick={openCreateModal} disabled={availableClasses.length === 0}>
<span class="btn-icon">+</span>
Nouvelle évaluation
</button>
</header>
{#if error}
<div class="alert alert-error">
<span class="alert-icon">&#9888;</span>
{error}
<button class="alert-close" onclick={() => (error = null)}>&times;</button>
</div>
{/if}
<div class="filters-row">
<SearchInput value={searchTerm} onSearch={handleSearch} placeholder="Rechercher par titre..." />
<div class="class-filter">
<select
value={filterClassId}
aria-label="Filtrer par classe"
onchange={(e) => handleClassFilter((e.target as HTMLSelectElement).value)}
>
<option value="">Toutes les classes</option>
{#each availableClasses as cls (cls.id)}
<option value={cls.id}>{cls.name}</option>
{/each}
</select>
</div>
</div>
{#if isLoading}
<div class="loading-state" aria-live="polite" role="status">
<div class="spinner"></div>
<p>Chargement des évaluations...</p>
</div>
{:else if evaluations.length === 0}
<div class="empty-state">
<span class="empty-icon">&#128221;</span>
{#if searchTerm}
<h2>Aucun résultat</h2>
<p>Aucune évaluation ne correspond à votre recherche</p>
<button class="btn-secondary" onclick={() => handleSearch('')}>Effacer la recherche</button>
{:else}
<h2>Aucune évaluation</h2>
<p>Commencez par créer votre première évaluation</p>
<button class="btn-primary" onclick={openCreateModal} disabled={availableClasses.length === 0}>
Créer une évaluation
</button>
{/if}
</div>
{:else}
<div class="evaluation-list">
{#each evaluations as ev (ev.id)}
<div class="evaluation-card">
<div class="evaluation-header">
<h3 class="evaluation-title">{ev.title}</h3>
<div class="evaluation-badges">
<span class="badge-scale" title="Barème">{formatGradeScale(ev.gradeScale)}</span>
<span class="badge-coeff" title="Coefficient">x{ev.coefficient}</span>
</div>
</div>
<div class="evaluation-meta">
<span class="meta-item" title="Classe">
<span class="meta-icon">&#127979;</span>
{ev.className ?? getClassName(ev.classId)}
</span>
<span class="meta-item" title="Matière">
<span class="meta-icon">&#128214;</span>
{ev.subjectName ?? getSubjectName(ev.subjectId)}
</span>
<span class="meta-item" title="Date d'évaluation">
<span class="meta-icon">&#128197;</span>
{formatDate(ev.evaluationDate)}
</span>
</div>
{#if ev.description}
<p class="evaluation-description">{ev.description}</p>
{/if}
{#if ev.status === 'published'}
<div class="evaluation-actions">
<a class="btn-primary btn-sm" href="/dashboard/teacher/evaluations/{ev.id}/grades">
Saisir les notes
</a>
<button class="btn-secondary btn-sm" onclick={() => openEditModal(ev)}>
Modifier
</button>
<button class="btn-danger btn-sm" onclick={() => openDeleteModal(ev)}>
Supprimer
</button>
</div>
{/if}
</div>
{/each}
</div>
<Pagination {currentPage} {totalPages} onPageChange={handlePageChange} />
{/if}
</div>
<!-- Create Modal -->
{#if showCreateModal}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="modal-overlay" onclick={closeCreateModal} role="presentation">
<div
class="modal"
role="dialog"
aria-modal="true"
aria-labelledby="create-modal-title"
tabindex="-1"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => { if (e.key === 'Escape') closeCreateModal(); }}
>
<header class="modal-header">
<h2 id="create-modal-title">Nouvelle évaluation</h2>
<button class="modal-close" onclick={closeCreateModal} aria-label="Fermer">&times;</button>
</header>
<form
class="modal-body"
onsubmit={(e) => {
e.preventDefault();
handleCreate();
}}
>
<div class="form-group">
<label for="ev-class">Classe *</label>
<select
id="ev-class"
bind:value={newClassId}
required
onchange={() => (newSubjectId = '')}
>
<option value="">-- Sélectionner une classe --</option>
{#each availableClasses as cls (cls.id)}
<option value={cls.id}>{cls.name}</option>
{/each}
</select>
</div>
<div class="form-group">
<label for="ev-subject">Matière *</label>
<select id="ev-subject" bind:value={newSubjectId} required disabled={!newClassId}>
<option value="">-- Sélectionner une matière --</option>
{#each availableSubjectsForCreate as subj (subj.id)}
<option value={subj.id}>{subj.name}</option>
{/each}
</select>
{#if newClassId && availableSubjectsForCreate.length === 0}
<small class="form-hint form-hint-warning">Aucune matière affectée pour cette classe</small>
{/if}
</div>
<div class="form-group">
<label for="ev-title">Titre *</label>
<input
type="text"
id="ev-title"
bind:value={newTitle}
placeholder="ex: Contrôle chapitre 5"
required
minlength="2"
maxlength="255"
/>
</div>
<div class="form-group">
<label for="ev-description">Description</label>
<textarea
id="ev-description"
bind:value={newDescription}
placeholder="Description de l'évaluation (optionnel)"
rows="3"
></textarea>
</div>
<div class="form-group">
<label for="ev-date">Date d'évaluation *</label>
<input
type="date"
id="ev-date"
bind:value={newEvaluationDate}
required
/>
</div>
<div class="form-row">
<div class="form-group form-group-half">
<label for="ev-scale">Barème *</label>
<input
type="number"
id="ev-scale"
bind:value={newGradeScale}
min="1"
max="100"
step="1"
required
/>
{#if gradeScalePreview}
<small class="form-hint">{gradeScalePreview}</small>
{/if}
</div>
<div class="form-group form-group-half">
<label for="ev-coeff">Coefficient</label>
<input
type="number"
id="ev-coeff"
bind:value={newCoefficient}
min="0.1"
max="10"
step="0.1"
/>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn-secondary" onclick={closeCreateModal}>Annuler</button>
<button type="submit" class="btn-primary" disabled={isSubmitting}>
{isSubmitting ? 'Création...' : 'Créer'}
</button>
</div>
</form>
</div>
</div>
{/if}
<!-- Edit Modal -->
{#if showEditModal && editEvaluation}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="modal-overlay" onclick={closeEditModal} role="presentation">
<div
class="modal"
role="dialog"
aria-modal="true"
aria-labelledby="edit-modal-title"
tabindex="-1"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => { if (e.key === 'Escape') closeEditModal(); }}
>
<header class="modal-header">
<h2 id="edit-modal-title">Modifier l'évaluation</h2>
<button class="modal-close" onclick={closeEditModal} aria-label="Fermer">&times;</button>
</header>
<form
class="modal-body"
onsubmit={(e) => {
e.preventDefault();
handleUpdate();
}}
>
<div class="form-group">
<label for="edit-title">Titre *</label>
<input
type="text"
id="edit-title"
bind:value={editTitle}
required
minlength="2"
maxlength="255"
/>
</div>
<div class="form-group">
<label for="edit-description">Description</label>
<textarea
id="edit-description"
bind:value={editDescription}
rows="3"
></textarea>
</div>
<div class="form-group">
<label for="edit-date">Date d'évaluation *</label>
<input
type="date"
id="edit-date"
bind:value={editEvaluationDate}
required
/>
</div>
<div class="form-row">
<div class="form-group form-group-half">
<label for="edit-scale">Barème</label>
<input
type="number"
id="edit-scale"
bind:value={editGradeScale}
min="1"
max="100"
step="1"
/>
</div>
<div class="form-group form-group-half">
<label for="edit-coeff">Coefficient</label>
<input
type="number"
id="edit-coeff"
bind:value={editCoefficient}
min="0.1"
max="10"
step="0.1"
/>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn-secondary" onclick={closeEditModal}>Annuler</button>
<button type="submit" class="btn-primary" disabled={isUpdating}>
{isUpdating ? 'Enregistrement...' : 'Enregistrer'}
</button>
</div>
</form>
</div>
</div>
{/if}
<!-- Delete Modal -->
{#if showDeleteModal && evaluationToDelete}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="modal-overlay" onclick={closeDeleteModal} role="presentation">
<div
class="modal"
role="alertdialog"
aria-modal="true"
aria-labelledby="delete-modal-title"
tabindex="-1"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => { if (e.key === 'Escape') closeDeleteModal(); }}
>
<header class="modal-header">
<h2 id="delete-modal-title">Supprimer l'évaluation</h2>
<button class="modal-close" onclick={closeDeleteModal} aria-label="Fermer">&times;</button>
</header>
<div class="modal-body">
<p>Voulez-vous vraiment supprimer l'évaluation <strong>{evaluationToDelete.title}</strong> ?</p>
<p class="text-muted">Cette action est irréversible.</p>
<div class="modal-footer">
<button type="button" class="btn-secondary" onclick={closeDeleteModal}>Annuler</button>
<button type="button" class="btn-danger" onclick={handleDelete} disabled={isDeleting}>
{isDeleting ? 'Suppression...' : 'Supprimer'}
</button>
</div>
</div>
</div>
</div>
{/if}
<style>
/* Page layout */
.evaluations-page {
max-width: 64rem;
margin: 0 auto;
padding: 1.5rem;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
margin-bottom: 1.5rem;
}
.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;
}
/* Buttons */
.btn-primary {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 1.25rem;
background: #3b82f6;
color: white;
border: none;
border-radius: 0.5rem;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.btn-primary:hover:not(:disabled) {
background: #2563eb;
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-secondary {
padding: 0.5rem 1rem;
background: white;
color: #374151;
border: 1px solid #d1d5db;
border-radius: 0.5rem;
font-size: 0.875rem;
cursor: pointer;
transition: background 0.2s;
}
.btn-secondary:hover {
background: #f9fafb;
}
.btn-danger {
padding: 0.5rem 1rem;
background: #dc2626;
color: white;
border: none;
border-radius: 0.5rem;
font-size: 0.875rem;
cursor: pointer;
transition: background 0.2s;
}
.btn-danger:hover:not(:disabled) {
background: #b91c1c;
}
.btn-danger:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-sm {
padding: 0.375rem 0.75rem;
font-size: 0.8125rem;
}
.btn-icon {
font-size: 1.125rem;
line-height: 1;
}
/* Filters */
.filters-row {
display: flex;
gap: 1rem;
margin-bottom: 1.5rem;
flex-wrap: wrap;
}
.class-filter select {
padding: 0.5rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.5rem;
font-size: 0.875rem;
background: white;
}
/* Alerts */
.alert {
display: flex;
align-items: flex-start;
gap: 0.75rem;
padding: 0.75rem 1rem;
border-radius: 0.5rem;
margin-bottom: 1rem;
}
.alert-error {
background: #fef2f2;
border: 1px solid #fecaca;
color: #991b1b;
}
.alert-icon {
flex-shrink: 0;
font-size: 1.125rem;
}
.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 & Empty states */
.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;
}
/* Evaluation list */
.evaluation-list {
display: flex;
flex-direction: column;
gap: 1rem;
}
.evaluation-card {
padding: 1.25rem;
background: white;
border: 1px solid #e5e7eb;
border-radius: 0.75rem;
transition: box-shadow 0.2s;
}
.evaluation-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.evaluation-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 0.75rem;
margin-bottom: 0.75rem;
}
.evaluation-title {
margin: 0;
font-size: 1.125rem;
font-weight: 600;
color: #1f2937;
}
.evaluation-badges {
display: flex;
gap: 0.5rem;
flex-shrink: 0;
}
.badge-scale {
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
background: #e0e7ff;
color: #4338ca;
}
.badge-coeff {
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 600;
background: #fef3c7;
color: #92400e;
}
.evaluation-meta {
display: flex;
gap: 1.25rem;
flex-wrap: wrap;
margin-bottom: 0.75rem;
}
.meta-item {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.875rem;
color: #6b7280;
}
.meta-icon {
font-size: 0.875rem;
}
.evaluation-description {
margin: 0 0 0.75rem;
font-size: 0.875rem;
color: #4b5563;
line-height: 1.5;
white-space: pre-line;
}
.evaluation-actions {
display: flex;
gap: 0.5rem;
}
/* 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: 32rem;
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;
color: #6b7280;
cursor: pointer;
}
.modal-close:hover {
color: #1f2937;
}
.modal-body {
padding: 1.5rem;
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 0.75rem;
padding-top: 1rem;
}
/* Forms */
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.375rem;
font-size: 0.875rem;
font-weight: 500;
color: #374151;
}
.form-group input,
.form-group select,
.form-group textarea {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.5rem;
font-size: 0.875rem;
box-sizing: border-box;
}
.form-group input:focus,
.form-group select:focus,
.form-group textarea: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;
}
.form-hint-warning {
color: #d97706;
}
.form-row {
display: flex;
gap: 1rem;
}
.form-group-half {
flex: 1;
}
.text-muted {
color: #6b7280;
font-size: 0.875rem;
}
/* Responsive */
@media (max-width: 640px) {
.page-header {
flex-direction: column;
}
.filters-row {
flex-direction: column;
}
.form-row {
flex-direction: column;
}
.evaluation-meta {
flex-direction: column;
gap: 0.5rem;
}
}
</style>