feat: Provisionner automatiquement un nouvel établissement
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

Lorsqu'un super-admin crée un établissement via l'interface, le système
doit automatiquement créer la base tenant, exécuter les migrations,
créer le premier utilisateur admin et envoyer l'invitation — le tout
de manière asynchrone pour ne pas bloquer la réponse HTTP.

Ce mécanisme rend chaque établissement opérationnel dès sa création
sans intervention manuelle sur l'infrastructure.
This commit is contained in:
2026-04-08 13:55:41 +02:00
parent bec211ebf0
commit dc2be898d5
171 changed files with 11703 additions and 700 deletions

View File

@@ -0,0 +1,563 @@
<script lang="ts">
export interface BlockedDate {
date: string;
reason: string;
type: string;
}
let {
value = '',
onSelect,
min = '',
blockedDates = [],
disabled = false,
id = ''
}: {
value?: string;
onSelect: (date: string) => void;
min?: string;
blockedDates?: BlockedDate[];
disabled?: boolean;
id?: string;
} = $props();
const DAYS = ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim'] as const;
const MONTHS = [
'Janvier',
'Février',
'Mars',
'Avril',
'Mai',
'Juin',
'Juillet',
'Août',
'Septembre',
'Octobre',
'Novembre',
'Décembre'
] as const;
const TYPE_COLORS: Record<string, string> = {
weekend: '#9ca3af',
holiday: '#ef4444',
vacation: '#3b82f6',
pedagogical_day: '#f59e0b',
rule_hard: '#dc2626',
rule_soft: '#f97316'
};
const TYPE_LABELS: Record<string, string> = {
weekend: 'Weekend',
holiday: 'Jour férié',
vacation: 'Vacances',
pedagogical_day: 'J. pédagogique',
rule_hard: 'Règle (bloquant)',
rule_soft: 'Règle (avertissement)'
};
let isOpen = $state(false);
let month = $state(new Date().getMonth());
let year = $state(new Date().getFullYear());
// Sync month/year when value changes externally
$effect(() => {
if (value) {
const d = new Date(value + 'T00:00:00');
month = d.getMonth();
year = d.getFullYear();
}
});
let monthLabel = $derived(`${MONTHS[month]} ${year}`);
let daysInMonth = $derived(new Date(year, month + 1, 0).getDate());
let firstDayOffset = $derived.by(() => {
const jsDay = new Date(year, month, 1).getDay();
return jsDay === 0 ? 6 : jsDay - 1;
});
// Build a Set for O(1) lookup of blocked dates
let blockedDateMap = $derived.by(() => {
const map = new Map<string, BlockedDate>();
for (const bd of blockedDates) {
map.set(bd.date, bd);
}
return map;
});
interface DayCell {
day: number;
dateStr: string;
isToday: boolean;
isWeekend: boolean;
isBlocked: boolean;
isWarning: boolean;
blockedReason: string;
blockedType: string;
isBeforeMin: boolean;
isSelected: boolean;
}
let calendarGrid = $derived.by(() => {
const today = new Date();
const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
const cells: (DayCell | null)[] = [];
for (let i = 0; i < firstDayOffset; i++) {
cells.push(null);
}
for (let d = 1; d <= daysInMonth; d++) {
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
const dayOfWeek = (firstDayOffset + d - 1) % 7;
const isWeekend = dayOfWeek >= 5;
const blocked = blockedDateMap.get(dateStr);
const isSoftRule = blocked?.type === 'rule_soft';
const isBlocked = isWeekend || (!!blocked && !isSoftRule);
const isBeforeMin = min !== '' && dateStr < min;
cells.push({
day: d,
dateStr,
isToday: dateStr === todayStr,
isWeekend,
isBlocked,
isWarning: isSoftRule,
blockedReason: blocked?.reason ?? (isWeekend ? 'Weekend' : ''),
blockedType: blocked?.type ?? (isWeekend ? 'weekend' : ''),
isBeforeMin,
isSelected: dateStr === value
});
}
return cells;
});
// Determine which blocked types are visible this month for the legend
let visibleBlockedTypes = $derived.by(() => {
const types = new Set<string>();
for (const cell of calendarGrid) {
if ((cell?.isBlocked || cell?.isWarning) && cell.blockedType) {
types.add(cell.blockedType);
}
}
return [...types];
});
// Navigation bounds: don't go before current month or beyond 3 months ahead
let canGoPrev = $derived.by(() => {
const now = new Date();
return year > now.getFullYear() || (year === now.getFullYear() && month > now.getMonth());
});
let canGoNext = $derived.by(() => {
const limit = new Date();
limit.setMonth(limit.getMonth() + 3);
return year < limit.getFullYear() || (year === limit.getFullYear() && month < limit.getMonth());
});
function prevMonth() {
if (!canGoPrev) return;
if (month === 0) {
month = 11;
year--;
} else {
month--;
}
}
function nextMonth() {
if (!canGoNext) return;
if (month === 11) {
month = 0;
year++;
} else {
month++;
}
}
function selectDate(dateStr: string) {
onSelect(dateStr);
isOpen = false;
}
function formatDisplayDate(dateStr: string): string {
if (!dateStr) return '';
const d = new Date(dateStr + 'T00:00:00');
return d.toLocaleDateString('fr-FR', {
weekday: 'long',
day: 'numeric',
month: 'long',
year: 'numeric'
});
}
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
isOpen = false;
}
}
function handleClickOutside(event: MouseEvent) {
const target = event.target as HTMLElement;
if (!target.closest('.calendar-date-picker')) {
isOpen = false;
}
}
$effect(() => {
if (isOpen) {
document.addEventListener('click', handleClickOutside, true);
return () => document.removeEventListener('click', handleClickOutside, true);
}
});
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="calendar-date-picker" class:disabled onkeydown={handleKeydown}>
{#if id}
<input
type="date"
{id}
value={value}
{min}
onchange={(e) => onSelect((e.target as HTMLInputElement).value)}
class="sr-only-input"
tabindex={-1}
aria-hidden="true"
/>
{/if}
<button
type="button"
class="picker-trigger"
onclick={() => {
if (!disabled) isOpen = !isOpen;
}}
aria-haspopup="dialog"
aria-expanded={isOpen}
{disabled}
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
aria-hidden="true"
>
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
{#if value}
<span class="picker-value">{formatDisplayDate(value)}</span>
{:else}
<span class="picker-placeholder">Choisir une date</span>
{/if}
</button>
{#if isOpen}
<div class="calendar-dropdown" role="dialog" aria-label="Calendrier">
<div class="calendar-header">
<button type="button" class="nav-btn" onclick={prevMonth} disabled={!canGoPrev} aria-label="Mois précédent">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<polyline points="15 18 9 12 15 6" />
</svg>
</button>
<span class="month-label">{monthLabel}</span>
<button type="button" class="nav-btn" onclick={nextMonth} disabled={!canGoNext} aria-label="Mois suivant">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
</div>
<div class="calendar-grid" role="grid" aria-label="Jours du mois">
<div class="calendar-row day-names" role="row">
{#each DAYS as day}
<div class="day-name" role="columnheader">{day}</div>
{/each}
</div>
{#each { length: Math.ceil(calendarGrid.length / 7) } as _, weekIndex}
<div class="calendar-row" role="row">
{#each calendarGrid.slice(weekIndex * 7, weekIndex * 7 + 7) as cell}
{#if cell === null}
<div class="day-cell empty" role="gridcell"></div>
{:else}
<button
type="button"
class="day-cell"
class:today={cell.isToday}
class:selected={cell.isSelected}
class:weekend={cell.isWeekend}
class:blocked={cell.isBlocked}
class:warning={cell.isWarning}
class:before-min={cell.isBeforeMin}
disabled={cell.isBlocked || cell.isBeforeMin}
onclick={() => selectDate(cell.dateStr)}
title={cell.isBlocked || cell.isWarning
? cell.blockedReason
: cell.isBeforeMin
? 'Date antérieure au minimum autorisé'
: ''}
role="gridcell"
aria-selected={cell.isSelected}
aria-disabled={cell.isBlocked || cell.isBeforeMin}
>
{cell.day}
{#if (cell.isBlocked || cell.isWarning) && !cell.isWeekend}
<span
class="blocked-dot"
style="background-color: {TYPE_COLORS[cell.blockedType] || '#9ca3af'}"
></span>
{/if}
</button>
{/if}
{/each}
</div>
{/each}
</div>
{#if visibleBlockedTypes.length > 0}
<div class="calendar-legend">
{#each visibleBlockedTypes as type}
<div class="legend-item">
<span class="legend-dot" style="background-color: {TYPE_COLORS[type] || '#9ca3af'}"></span>
<span class="legend-label">{TYPE_LABELS[type] || type}</span>
</div>
{/each}
</div>
{/if}
</div>
{/if}
</div>
<style>
.sr-only-input {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
border: 0;
}
.calendar-date-picker {
position: relative;
display: inline-block;
width: 100%;
}
.calendar-date-picker.disabled {
opacity: 0.6;
pointer-events: none;
}
.picker-trigger {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid #d1d5db;
border-radius: 0.375rem;
background: white;
color: #374151;
font-size: 0.875rem;
cursor: pointer;
text-align: left;
transition: border-color 0.15s, box-shadow 0.15s;
}
.picker-trigger:hover:not(:disabled) {
border-color: #93c5fd;
}
.picker-trigger:focus {
outline: none;
border-color: #3b82f6;
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.picker-placeholder {
color: #9ca3af;
}
.calendar-dropdown {
position: absolute;
top: 100%;
left: 0;
z-index: 50;
margin-top: 0.25rem;
padding: 0.75rem;
background: white;
border: 1px solid #e5e7eb;
border-radius: 0.5rem;
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -4px rgba(0, 0, 0, 0.1);
min-width: 280px;
}
.calendar-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
}
.month-label {
font-weight: 600;
font-size: 0.875rem;
color: #1f2937;
}
.nav-btn {
display: flex;
align-items: center;
justify-content: center;
width: 1.75rem;
height: 1.75rem;
border: none;
border-radius: 0.25rem;
background: transparent;
color: #6b7280;
cursor: pointer;
transition: background-color 0.15s;
}
.nav-btn:hover {
background: #f3f4f6;
color: #374151;
}
.calendar-grid {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.calendar-row {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 0.125rem;
}
.day-names {
margin-bottom: 0.25rem;
}
.day-name {
display: flex;
align-items: center;
justify-content: center;
font-size: 0.6875rem;
font-weight: 600;
color: #9ca3af;
text-transform: uppercase;
height: 1.75rem;
}
.day-cell {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 2.25rem;
height: 2.25rem;
border: none;
border-radius: 0.375rem;
background: transparent;
color: #374151;
font-size: 0.8125rem;
cursor: pointer;
transition: background-color 0.15s, color 0.15s;
}
.day-cell.empty {
cursor: default;
}
.day-cell:not(.empty):not(:disabled):hover {
background: #eff6ff;
color: #2563eb;
}
.day-cell.today {
font-weight: 700;
box-shadow: inset 0 0 0 1px #3b82f6;
}
.day-cell.selected {
background: #3b82f6;
color: white;
font-weight: 600;
}
.day-cell.selected:hover {
background: #2563eb;
color: white;
}
.day-cell.weekend {
color: #d1d5db;
cursor: not-allowed;
}
.day-cell.blocked:not(.weekend) {
color: #d1d5db;
cursor: not-allowed;
}
.day-cell.warning {
color: #9a3412;
background: #fff7ed;
}
.day-cell.before-min {
color: #e5e7eb;
cursor: not-allowed;
}
.blocked-dot {
position: absolute;
bottom: 0.125rem;
left: 50%;
transform: translateX(-50%);
width: 0.25rem;
height: 0.25rem;
border-radius: 50%;
}
.calendar-legend {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
margin-top: 0.75rem;
padding-top: 0.5rem;
border-top: 1px solid #f3f4f6;
}
.legend-item {
display: flex;
align-items: center;
gap: 0.25rem;
}
.legend-dot {
width: 0.375rem;
height: 0.375rem;
border-radius: 50%;
}
.legend-label {
font-size: 0.6875rem;
color: #6b7280;
}
</style>

View File

@@ -34,6 +34,7 @@
let files = $state<UploadedFile[]>(existingFiles);
let pendingFiles = $state<{ name: string; size: number }[]>([]);
let error = $state<string | null>(null);
let isDragging = $state(false);
let fileInput: HTMLInputElement;
$effect(() => {
@@ -52,6 +53,15 @@
return '📎';
}
function formatFileType(mimeType: string): string {
if (mimeType === 'application/pdf') return 'PDF';
if (mimeType === 'image/jpeg') return 'JPEG';
if (mimeType === 'image/png') return 'PNG';
if (mimeType.includes('wordprocessingml')) return 'DOCX';
const parts = mimeType.split('/');
return parts[1]?.toUpperCase() ?? mimeType;
}
function validateFile(file: File): string | null {
if (!acceptedTypes.includes(file.type)) {
return `Type de fichier non accepté : ${file.type}.`;
@@ -62,14 +72,10 @@
return null;
}
async function handleFileSelect(event: Event) {
const input = event.target as HTMLInputElement;
const selectedFiles = input.files;
if (!selectedFiles || selectedFiles.length === 0) return;
async function processFiles(fileList: globalThis.FileList) {
error = null;
for (const file of selectedFiles) {
for (const file of fileList) {
const validationError = validateFile(file);
if (validationError) {
error = validationError;
@@ -87,10 +93,37 @@
pendingFiles = pendingFiles.filter((p) => p.name !== file.name);
}
}
}
async function handleFileSelect(event: Event) {
const input = event.target as HTMLInputElement;
const selectedFiles = input.files;
if (!selectedFiles || selectedFiles.length === 0) return;
await processFiles(selectedFiles);
input.value = '';
}
function handleDragOver(event: DragEvent) {
event.preventDefault();
isDragging = true;
}
function handleDragLeave(event: DragEvent) {
const target = event.currentTarget as HTMLElement;
const related = event.relatedTarget as globalThis.Node | null;
if (related && target.contains(related)) return;
isDragging = false;
}
async function handleDrop(event: DragEvent) {
event.preventDefault();
isDragging = false;
const droppedFiles = event.dataTransfer?.files;
if (!droppedFiles || droppedFiles.length === 0) return;
await processFiles(droppedFiles);
}
async function handleDelete(fileId: string) {
error = null;
@@ -114,6 +147,7 @@
<li class="file-item">
<span class="file-icon">{getFileIcon(file.mimeType)}</span>
<span class="file-name">{file.filename}</span>
<span class="file-type">{formatFileType(file.mimeType)}</span>
<span class="file-size">{formatFileSize(file.fileSize)}</span>
{#if !disabled && showDelete}
<button
@@ -140,22 +174,38 @@
{/if}
{#if !disabled}
<button type="button" class="upload-btn" onclick={() => fileInput.click()}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<path d="M21.44 11.05l-9.19 9.19a6 6 0 01-8.49-8.49l9.19-9.19a4 4 0 015.66 5.66l-9.2 9.19a2 2 0 01-2.83-2.83l8.49-8.48" />
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="drop-zone"
class:drop-zone-active={isDragging}
ondragover={handleDragOver}
ondragleave={handleDragLeave}
ondrop={handleDrop}
onclick={() => fileInput.click()}
>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true">
<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
Ajouter un fichier
</button>
<p class="drop-zone-text">
Glissez-déposez vos fichiers ici ou
<button type="button" class="drop-zone-browse" onclick={(e) => { e.stopPropagation(); fileInput.click(); }}>
parcourir
</button>
</p>
<p class="upload-hint">{hint}</p>
</div>
<input
bind:this={fileInput}
type="file"
accept={acceptAttr}
onchange={handleFileSelect}
multiple
class="file-input-hidden"
aria-hidden="true"
tabindex="-1"
/>
<p class="upload-hint">{hint}</p>
{/if}
</div>
@@ -216,6 +266,16 @@
white-space: nowrap;
}
.file-type {
color: #6b7280;
font-size: 0.6875rem;
font-weight: 500;
padding: 0.0625rem 0.375rem;
background: #f3f4f6;
border-radius: 0.25rem;
flex-shrink: 0;
}
.file-size {
color: #9ca3af;
font-size: 0.75rem;
@@ -249,25 +309,51 @@
color: #dc2626;
}
.upload-btn {
display: inline-flex;
.drop-zone {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.375rem;
padding: 0.5rem 0.75rem;
border: 1px dashed #d1d5db;
border-radius: 0.375rem;
background: white;
color: #374151;
font-size: 0.875rem;
gap: 0.5rem;
padding: 1.5rem;
border: 2px dashed #d1d5db;
border-radius: 0.5rem;
background: #fafafa;
color: #6b7280;
cursor: pointer;
transition: border-color 0.15s, background-color 0.15s;
align-self: flex-start;
}
.upload-btn:hover {
border-color: #3b82f6;
.drop-zone:hover {
border-color: #93c5fd;
background: #eff6ff;
}
.drop-zone-active {
border-color: #3b82f6;
background: #dbeafe;
}
.drop-zone-text {
margin: 0;
font-size: 0.875rem;
color: #6b7280;
text-align: center;
}
.drop-zone-browse {
display: inline;
padding: 0;
border: none;
background: none;
color: #2563eb;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
text-decoration: underline;
}
.drop-zone-browse:hover {
color: #1d4ed8;
}
.file-input-hidden {

View File

@@ -25,13 +25,23 @@
endDate: string;
}
interface QuickStats {
totalClasses: number;
totalEvaluations: number;
totalStudents: number;
globalAverage: number | null;
}
let replacedClasses = $state<ReplacedClass[]>([]);
let replacementsLoading = $state(false);
let quickStats = $state<QuickStats | null>(null);
let statsLoading = $state(false);
$effect(() => {
untrack(() => {
if (isAuthenticated()) {
loadReplacedClasses();
loadQuickStats();
}
});
});
@@ -53,6 +63,35 @@
}
}
async function loadQuickStats() {
try {
statsLoading = true;
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(`${apiUrl}/me/statistics`);
if (!response.ok) return;
const data = await response.json();
const classes: { evaluationCount: number; studentCount: number; average: number | null }[] = data.classes ?? [];
if (classes.length === 0) return;
const totalEvaluations = classes.reduce((s, c) => s + (c.evaluationCount ?? 0), 0);
const averages = classes.map(c => c.average).filter((a): a is number => a != null);
const globalAverage = averages.length > 0
? Math.round((averages.reduce((s, a) => s + a, 0) / averages.length) * 100) / 100
: null;
quickStats = {
totalClasses: classes.length,
totalEvaluations,
totalStudents: classes.reduce((s, c) => s + (c.studentCount ?? 0), 0),
globalAverage,
};
} catch {
// Non-critical
} finally {
statsLoading = false;
}
}
function daysRemaining(endDate: string): number {
const end = new Date(endDate);
const now = new Date();
@@ -158,11 +197,31 @@
<DashboardSection
title="Statistiques rapides"
isPlaceholder={!hasRealData}
placeholderMessage="Les statistiques de vos classes seront disponibles prochainement"
isPlaceholder={!quickStats && !statsLoading}
placeholderMessage="Les statistiques apparaîtront après la publication de notes"
>
{#if hasRealData && isLoading}
{#if statsLoading}
<SkeletonList items={2} message="Chargement des statistiques..." />
{:else if quickStats}
<div class="quick-stats">
<div class="quick-stat">
<span class="quick-stat-value">{quickStats.globalAverage != null ? quickStats.globalAverage.toFixed(1) : '—'}</span>
<span class="quick-stat-label">Moyenne générale</span>
</div>
<div class="quick-stat">
<span class="quick-stat-value">{quickStats.totalClasses}</span>
<span class="quick-stat-label">Classe{quickStats.totalClasses > 1 ? 's' : ''}</span>
</div>
<div class="quick-stat">
<span class="quick-stat-value">{quickStats.totalEvaluations}</span>
<span class="quick-stat-label">Évaluation{quickStats.totalEvaluations > 1 ? 's' : ''}</span>
</div>
<div class="quick-stat">
<span class="quick-stat-value">{quickStats.totalStudents}</span>
<span class="quick-stat-label">Élève{quickStats.totalStudents > 1 ? 's' : ''}</span>
</div>
</div>
<a href="/dashboard/teacher/statistics" class="stats-link">Voir les statistiques détaillées</a>
{/if}
</DashboardSection>
</div>
@@ -309,6 +368,49 @@
color: #dc2626;
}
/* Quick Stats */
.quick-stats {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 1rem;
}
.quick-stat {
display: flex;
flex-direction: column;
align-items: center;
padding: 0.75rem 0.5rem;
background: #f9fafb;
border-radius: 0.5rem;
}
.quick-stat-value {
font-size: 1.5rem;
font-weight: 700;
color: #1e293b;
}
.quick-stat-label {
font-size: 0.75rem;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.stats-link {
display: block;
margin-top: 1rem;
text-align: center;
color: #3b82f6;
font-size: 0.875rem;
font-weight: 500;
text-decoration: none;
}
.stats-link:hover {
text-decoration: underline;
}
@media (min-width: 768px) {
.dashboard-grid {
grid-template-columns: repeat(2, 1fr);

View File

@@ -106,6 +106,7 @@
{#if isProf}
<a href="/dashboard/teacher/homework" class="nav-link" class:active={pathname === '/dashboard/teacher/homework'}>Devoirs</a>
<a href="/dashboard/teacher/evaluations" class="nav-link" class:active={pathname === '/dashboard/teacher/evaluations'}>Évaluations</a>
<a href="/dashboard/teacher/statistics" class="nav-link" class:active={pathname.startsWith('/dashboard/teacher/statistics')}>Mes statistiques</a>
{/if}
{#if isEleve}
<a href="/dashboard/schedule" class="nav-link" class:active={pathname === '/dashboard/schedule'}>Mon EDT</a>
@@ -161,6 +162,9 @@
<a href="/dashboard/teacher/evaluations" class="mobile-nav-link" class:active={pathname === '/dashboard/teacher/evaluations'}>
Évaluations
</a>
<a href="/dashboard/teacher/statistics" class="mobile-nav-link" class:active={pathname.startsWith('/dashboard/teacher/statistics')}>
Mes statistiques
</a>
{/if}
{#if isEleve}
<a href="/dashboard/schedule" class="mobile-nav-link" class:active={pathname === '/dashboard/schedule'}>

View File

@@ -1252,6 +1252,22 @@
flex-direction: column;
gap: 0.5rem;
}
.evaluation-actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.375rem;
width: 100%;
}
.evaluation-actions .btn-sm {
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
padding: 0.5rem 0.5rem;
white-space: nowrap;
}
}
.checkbox-label {

View File

@@ -27,6 +27,8 @@
error: string | null;
appreciation: string | null;
appreciationDirty: boolean;
createdByName: string | null;
isReplacement: boolean;
}
interface AppreciationTemplate {
@@ -188,6 +190,8 @@
value: number | null;
status: string | null;
appreciation: string | null;
createdByName: string | null;
isReplacement: boolean | null;
}> = gradesData['hydra:member'] ?? gradesData['member'] ?? (Array.isArray(gradesData) ? gradesData : []);
students = gradeRows.map((row) => {
@@ -209,6 +213,8 @@
error: null,
appreciation: row.appreciation ?? null,
appreciationDirty: false,
createdByName: row.createdByName ?? null,
isReplacement: row.isReplacement ?? false,
};
});
} catch (e) {
@@ -366,24 +372,39 @@
);
}
// Parse response to update gradeIds
// Parse response to update gradeIds and replacement info
const savedData = await response.json().catch(() => null);
const savedGrades: Array<{ id: string; studentId: string }> =
const savedGrades: Array<{
id: string;
studentId: string;
createdByName: string | null;
isReplacement: boolean | null;
}> =
savedData?.['hydra:member'] ?? savedData?.['member'] ?? (Array.isArray(savedData) ? savedData : []);
const gradeIdByStudent = new Map<string, string>();
const savedByStudent = new Map<string, { id: string; createdByName: string | null; isReplacement: boolean }>();
for (const sg of savedGrades) {
if (sg.studentId && sg.id) {
gradeIdByStudent.set(sg.studentId, sg.id);
savedByStudent.set(sg.studentId, {
id: sg.id,
createdByName: sg.createdByName ?? null,
isReplacement: sg.isReplacement ?? false,
});
}
}
// Mark saved rows as clean and update gradeIds
students = students.map((s) =>
dirtyRows.some((d) => d.studentId === s.studentId)
? { ...s, dirty: false, gradeId: gradeIdByStudent.get(s.studentId) ?? s.gradeId }
: s
);
// Mark saved rows as clean and update gradeIds + replacement badge
students = students.map((s) => {
if (!dirtyRows.some((d) => d.studentId === s.studentId)) return s;
const saved = savedByStudent.get(s.studentId);
return {
...s,
dirty: false,
gradeId: saved?.id ?? s.gradeId,
createdByName: saved?.createdByName ?? s.createdByName,
isReplacement: saved?.isReplacement ?? s.isReplacement,
};
});
// Flush buffered appreciations that were waiting for a gradeId
for (let idx = 0; idx < students.length; idx++) {
@@ -865,6 +886,9 @@
<div class="grid-row" role="row" class:row-absent={row.status === 'absent'} class:row-dispensed={row.status === 'dispensed'} class:row-error={row.error !== null}>
<div class="cell-name" role="gridcell">
<span class="student-name">{row.studentName}</span>
{#if row.isReplacement && row.createdByName}
<span class="badge-replacement" title="Note saisie par {row.createdByName}">Remplaçant</span>
{/if}
</div>
<div class="cell-grade" role="gridcell">
<div class="input-wrapper">
@@ -1339,6 +1363,20 @@
color: #1f2937;
}
.badge-replacement {
display: inline-block;
margin-left: 0.375rem;
padding: 0.125rem 0.375rem;
font-size: 0.625rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.025em;
color: #9333ea;
background-color: #f3e8ff;
border-radius: 0.25rem;
vertical-align: middle;
}
.input-wrapper {
position: relative;
width: 100%;

View File

@@ -6,6 +6,8 @@
import Pagination from '$lib/components/molecules/Pagination/Pagination.svelte';
import ExceptionRequestModal from '$lib/components/molecules/ExceptionRequestModal/ExceptionRequestModal.svelte';
import RuleBlockedModal from '$lib/components/molecules/RuleBlockedModal/RuleBlockedModal.svelte';
import CalendarDatePicker from '$lib/components/molecules/CalendarDatePicker/CalendarDatePicker.svelte';
import type { BlockedDate } from '$lib/components/molecules/CalendarDatePicker/CalendarDatePicker.svelte';
import FileUpload from '$lib/components/molecules/FileUpload/FileUpload.svelte';
import RichTextEditor from '$lib/components/molecules/RichTextEditor/RichTextEditor.svelte';
import SearchInput from '$lib/components/molecules/SearchInput/SearchInput.svelte';
@@ -86,6 +88,14 @@
let isSubmitting = $state(false);
let newPendingFiles = $state<File[]>([]);
// Blocked dates for calendar
let calendarBlockedDates = $state<BlockedDate[]>([]);
// Edit calendar: rules don't apply (homework already assigned to students)
let editCalendarBlockedDates = $derived(
calendarBlockedDates.filter((d) => !d.type.startsWith('rule_'))
);
// Attachments
let editAttachments = $state<HomeworkAttachmentFile[]>([]);
@@ -96,6 +106,7 @@
let editDescription = $state('');
let editDueDate = $state('');
let isUpdating = $state(false);
let editError = $state<string | null>(null);
// Delete modal
let showDeleteModal = $state(false);
@@ -158,7 +169,10 @@
// Load on mount
$effect(() => {
untrack(() => loadAll());
untrack(() => {
loadAll();
loadBlockedDates();
});
});
function extractCollection<T>(data: Record<string, unknown>): T[] {
@@ -168,6 +182,28 @@
return [];
}
async function loadBlockedDates() {
try {
const apiUrl = getApiBaseUrl();
const today = new Date();
const startDate = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`;
// Load 3 months ahead (stays within current academic year boundary)
const endDate = new Date(today.getFullYear(), today.getMonth() + 4, 0);
const endDateStr = `${endDate.getFullYear()}-${String(endDate.getMonth() + 1).padStart(2, '0')}-${String(endDate.getDate()).padStart(2, '0')}`;
const res = await authenticatedFetch(
`${apiUrl}/schedule/blocked-dates?startDate=${startDate}&endDate=${endDateStr}`
);
if (res.ok) {
const data = await res.json();
calendarBlockedDates = extractCollection<BlockedDate>(data);
}
} catch {
// Silent fail — calendar will work without blocked dates
}
}
async function loadAssignments() {
const userId = await getAuthenticatedUserId();
if (!userId) return;
@@ -602,6 +638,7 @@
editDescription = hw.description ?? '';
editDueDate = hw.dueDate;
editAttachments = [];
editError = null;
showEditModal = true;
// Charger les pièces jointes existantes en arrière-plan
@@ -619,7 +656,7 @@
try {
isUpdating = true;
error = null;
editError = null;
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(`${apiUrl}/homework/${editHomework.id}`, {
method: 'PATCH',
@@ -644,7 +681,7 @@
closeEditModal();
await loadHomeworks();
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur lors de la modification';
editError = e instanceof Error ? e.message : 'Erreur lors de la modification';
} finally {
isUpdating = false;
}
@@ -986,14 +1023,14 @@
</div>
<div class="form-group">
<label for="hw-due-date">Date d'échéance *</label>
<input
type="date"
<label>Date d'échéance *</label>
<CalendarDatePicker
id="hw-due-date"
value={newDueDate}
oninput={(e) => handleDueDateChange((e.target as HTMLInputElement).value)}
required
onSelect={(date) => handleDueDateChange(date)}
min={ruleConformMinDate || minDueDate}
blockedDates={calendarBlockedDates}
disabled={isSubmitting}
/>
{#if dueDateError}
<small class="form-hint form-hint-error">{dueDateError}</small>
@@ -1001,8 +1038,6 @@
<small class="form-hint form-hint-rule">
Date minimale conforme aux règles : {new Date(ruleConformMinDate + 'T00:00:00').toLocaleDateString('fr-FR', { day: 'numeric', month: 'long', year: 'numeric' })}
</small>
{:else}
<small class="form-hint">La date doit être au minimum demain, hors jours fériés et vacances</small>
{/if}
</div>
@@ -1073,6 +1108,14 @@
handleUpdate();
}}
>
{#if editError}
<div class="alert alert-error" style="margin-bottom: 1rem;">
<span class="alert-icon">&#9888;</span>
{editError}
<button class="alert-close" onclick={() => (editError = null)}>&times;</button>
</div>
{/if}
<div class="form-info">
<span class="info-label">Classe :</span>
<span>{editHomework.className ?? getClassName(editHomework.classId)}</span>
@@ -1103,8 +1146,15 @@
</div>
<div class="form-group">
<label for="edit-due-date">Date d'échéance *</label>
<input type="date" id="edit-due-date" bind:value={editDueDate} required min={minDueDate} />
<label>Date d'échéance *</label>
<CalendarDatePicker
id="edit-due-date"
value={editDueDate}
onSelect={(date) => (editDueDate = date)}
min={minDueDate}
blockedDates={editCalendarBlockedDates}
disabled={isUpdating}
/>
</div>
{#if editHomework}

View File

@@ -0,0 +1,785 @@
<script lang="ts">
import { onMount } from 'svelte';
import { authenticatedFetch } from '$lib/auth/auth.svelte';
import { getApiBaseUrl } from '$lib/api/config';
interface ClassOverview {
classId: string;
className: string;
subjectId: string;
subjectName: string;
evaluationCount: number;
studentCount: number;
average: number | null;
successRate: number | null;
}
interface StudentAverage {
studentId: string;
studentName: string;
average: number | null;
inDifficulty: boolean;
trend: string;
}
interface ClassDetail {
average: number | null;
successRate: number;
distribution: number[];
evolution: { month: string; average: number }[];
students: StudentAverage[];
}
interface GradePoint {
date: string;
value: number;
evaluationTitle: string;
}
interface StudentProgression {
grades: GradePoint[];
trendLine: { slope: number; intercept: number } | null;
}
interface EvaluationDifficulty {
evaluationId: string;
title: string;
classId: string;
className: string;
subjectId: string;
subjectName: string;
date: string;
average: number | null;
gradedCount: number;
subjectAverage: number | null;
percentile: number | null;
}
type View = 'overview' | 'class-detail' | 'student-progression';
let isLoading = $state(true);
let error = $state('');
let overview = $state<ClassOverview[]>([]);
let evaluationDifficulties = $state<EvaluationDifficulty[]>([]);
let currentView = $state<View>('overview');
let selectedClass = $state<ClassOverview | null>(null);
let classDetail = $state<ClassDetail | null>(null);
let isLoadingDetail = $state(false);
let selectedStudent = $state<StudentAverage | null>(null);
let studentProgression = $state<StudentProgression | null>(null);
let isLoadingProgression = $state(false);
const distributionLabels = ['0-2.5', '2.5-5', '5-7.5', '7.5-10', '10-12.5', '12.5-15', '15-17.5', '17.5-20'];
const monthLabels: Record<string, string> = {
'01': 'Jan', '02': 'Fév', '03': 'Mar', '04': 'Avr', '05': 'Mai', '06': 'Juin',
'07': 'Juil', '08': 'Août', '09': 'Sep', '10': 'Oct', '11': 'Nov', '12': 'Déc',
};
let maxBin = $derived(classDetail ? Math.max(...classDetail.distribution, 1) : 1);
let evoValues = $derived(classDetail ? classDetail.evolution.map(e => e.average) : []);
let evoMinY = $derived(evoValues.length > 0 ? Math.max(0, Math.min(...evoValues) - 2) : 0);
let evoMaxY = $derived(evoValues.length > 0 ? Math.min(20, Math.max(...evoValues) + 2) : 20);
let evoRangeY = $derived(evoMaxY - evoMinY || 1);
let progGrades = $derived(studentProgression ? studentProgression.grades : []);
let progValues = $derived(progGrades.map(g => g.value));
let progMinY = $derived(progValues.length > 0 ? Math.max(0, Math.min(...progValues) - 2) : 0);
let progMaxY = $derived(progValues.length > 0 ? Math.min(20, Math.max(...progValues) + 2) : 20);
let progRangeY = $derived(progMaxY - progMinY || 1);
let progWidth = $derived(progGrades.length * 80);
function chartY(value: number, minY: number, rangeY: number): number {
return 190 - ((value - minY) / rangeY) * 180;
}
onMount(() => {
loadOverview();
loadEvaluationDifficulties();
});
async function loadOverview() {
isLoading = true;
error = '';
try {
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(`${apiUrl}/me/statistics`);
if (!response.ok) throw new Error('Erreur lors du chargement des statistiques');
const data = await response.json();
overview = data.classes ?? [];
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur inconnue';
} finally {
isLoading = false;
}
}
async function loadEvaluationDifficulties() {
try {
const apiUrl = getApiBaseUrl();
const response = await authenticatedFetch(`${apiUrl}/me/statistics/evaluations`);
if (!response.ok) return;
const data = await response.json();
evaluationDifficulties = data.evaluations ?? [];
} catch {
// Non-bloquant, la section sera simplement vide
}
}
async function selectClass(cls: ClassOverview) {
error = '';
classDetail = null;
selectedClass = cls;
currentView = 'class-detail';
isLoadingDetail = true;
try {
const apiUrl = getApiBaseUrl();
const params = new URLSearchParams({ subjectId: cls.subjectId });
const response = await authenticatedFetch(`${apiUrl}/me/statistics/classes/${cls.classId}?${params}`);
if (!response.ok) throw new Error('Erreur lors du chargement du détail');
const data: ClassDetail = await response.json();
classDetail = {
average: data.average ?? null,
successRate: data.successRate ?? 0,
distribution: data.distribution ?? [],
evolution: data.evolution ?? [],
students: (data.students ?? []).map((s) => ({
...s,
average: s.average ?? null,
inDifficulty: s.inDifficulty ?? false,
trend: s.trend ?? 'stable',
})),
};
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur inconnue';
} finally {
isLoadingDetail = false;
}
}
async function selectStudent(student: StudentAverage) {
if (!selectedClass) return;
error = '';
studentProgression = null;
selectedStudent = student;
currentView = 'student-progression';
isLoadingProgression = true;
try {
const apiUrl = getApiBaseUrl();
const params = new URLSearchParams({
subjectId: selectedClass.subjectId,
classId: selectedClass.classId,
});
const response = await authenticatedFetch(`${apiUrl}/me/statistics/students/${student.studentId}?${params}`);
if (!response.ok) throw new Error('Erreur lors du chargement de la progression');
const data: StudentProgression = await response.json();
studentProgression = {
grades: data.grades ?? [],
trendLine: data.trendLine ?? null,
};
} catch (e) {
error = e instanceof Error ? e.message : 'Erreur inconnue';
} finally {
isLoadingProgression = false;
}
}
function goBack() {
error = '';
if (currentView === 'student-progression') {
currentView = 'class-detail';
selectedStudent = null;
studentProgression = null;
} else if (currentView === 'class-detail') {
currentView = 'overview';
selectedClass = null;
classDetail = null;
}
}
async function exportCsv() {
if (!selectedClass) return;
const apiUrl = getApiBaseUrl();
const params = new URLSearchParams({
classId: selectedClass.classId,
subjectId: selectedClass.subjectId,
className: selectedClass.className,
subjectName: selectedClass.subjectName,
});
const response = await authenticatedFetch(`${apiUrl}/me/statistics/export?${params}`);
if (!response.ok) return;
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `statistiques-${selectedClass.className}-${selectedClass.subjectName}.csv`;
a.click();
URL.revokeObjectURL(url);
}
function formatMonth(m: string): string {
const monthKey = m.split('-')[1] as string | undefined;
return (monthKey && monthLabels[monthKey]) ?? m;
}
function trendIcon(trend: string): string {
if (trend === 'improving') return '\u2191';
if (trend === 'declining') return '\u2193';
return '\u2192';
}
function trendClass(trend: string): string {
if (trend === 'improving') return 'trend-up';
if (trend === 'declining') return 'trend-down';
return 'trend-stable';
}
</script>
<svelte:head>
<title>Mes statistiques - Classeo</title>
</svelte:head>
<div class="statistics-page">
<header class="page-header">
<div class="header-left">
{#if currentView !== 'overview'}
<button class="back-link" onclick={goBack}>&larr; Retour</button>
{/if}
<h1>
{#if currentView === 'overview'}
Mes statistiques
{:else if currentView === 'class-detail' && selectedClass}
{selectedClass.className}{selectedClass.subjectName}
{:else if currentView === 'student-progression' && selectedStudent}
Progression de {selectedStudent.studentName}
{/if}
</h1>
</div>
{#if currentView === 'class-detail'}
<div class="header-actions">
<button class="btn-secondary" onclick={exportCsv}>Exporter CSV</button>
<button class="btn-secondary" onclick={() => window.print()}>Imprimer / PDF</button>
</div>
{/if}
</header>
{#if error}
<div class="error-banner" role="alert">{error}</div>
{/if}
<!-- OVERVIEW -->
{#if currentView === 'overview'}
{#if isLoading}
<div class="loading">Chargement des statistiques...</div>
{:else if overview.length === 0}
<div class="empty-state">
<p>Aucune donnée statistique disponible pour la période en cours.</p>
<p>Les statistiques apparaîtront après la publication de notes.</p>
</div>
{:else}
<div class="overview-grid">
{#each overview as cls}
<button class="class-card" onclick={() => selectClass(cls)}>
<div class="card-header">
<span class="card-class">{cls.className}</span>
<span class="card-subject">{cls.subjectName}</span>
</div>
<div class="card-stats">
<div class="stat">
<span class="stat-value">{cls.average != null ? cls.average.toFixed(1) : '—'}</span>
<span class="stat-label">Moyenne</span>
</div>
<div class="stat">
<span class="stat-value">{cls.successRate != null ? `${cls.successRate.toFixed(0)}%` : '—'}</span>
<span class="stat-label">Réussite</span>
</div>
<div class="stat">
<span class="stat-value">{cls.evaluationCount}</span>
<span class="stat-label">Évaluations</span>
</div>
<div class="stat">
<span class="stat-value">{cls.studentCount}</span>
<span class="stat-label">Élèves</span>
</div>
</div>
</button>
{/each}
</div>
<!-- D1: Evaluation Difficulty Section (AC4) -->
{#if evaluationDifficulties.length > 0}
<section class="chart-section" style="margin-top: 1.5rem;">
<h2>Mes évaluations — Analyse de difficulté</h2>
<div class="student-table-wrapper">
<table class="student-table">
<thead>
<tr>
<th>Évaluation</th>
<th class="hide-mobile">Classe</th>
<th class="hide-mobile">Matière</th>
<th>Date</th>
<th>Moyenne</th>
<th class="hide-mobile">Moy. matière</th>
<th class="hide-mobile">Percentile</th>
</tr>
</thead>
<tbody>
{#each evaluationDifficulties as ev}
<tr>
<td>{ev.title}</td>
<td class="hide-mobile">{ev.className}</td>
<td class="hide-mobile">{ev.subjectName}</td>
<td class="text-center">{ev.date}</td>
<td class="text-center">{ev.average != null ? ev.average.toFixed(1) : '—'}</td>
<td class="text-center hide-mobile">{ev.subjectAverage != null ? ev.subjectAverage.toFixed(1) : '—'}</td>
<td class="text-center hide-mobile">{ev.percentile != null ? `${ev.percentile.toFixed(0)}%` : '—'}</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
{/if}
{/if}
{/if}
<!-- CLASS DETAIL -->
{#if currentView === 'class-detail'}
{#if isLoadingDetail}
<div class="loading">Chargement du détail...</div>
{:else if classDetail}
<div class="detail-layout">
<!-- Summary Cards -->
<div class="summary-row">
<div class="summary-card">
<span class="summary-value">{classDetail.average != null ? classDetail.average.toFixed(2) : '—'}</span>
<span class="summary-label">Moyenne de classe</span>
</div>
<div class="summary-card">
<span class="summary-value">{classDetail.successRate.toFixed(0)}%</span>
<span class="summary-label">Taux de réussite (≥ 10/20)</span>
</div>
</div>
<!-- Distribution Histogram -->
<section class="chart-section">
<h2>Répartition des notes</h2>
<div class="histogram">
{#each classDetail.distribution as count, i}
<div class="histogram-bar-wrapper">
<div class="histogram-bar" style="height: {(count / maxBin) * 100}%">
{#if count > 0}<span class="bar-count">{count}</span>{/if}
</div>
<span class="bar-label">{distributionLabels[i]}</span>
</div>
{/each}
</div>
</section>
<!-- Evolution Chart (P13: fallback message) -->
{#if classDetail.evolution.length >= 2}
<section class="chart-section">
<h2>Évolution sur l'année</h2>
<div class="line-chart" role="img" aria-label="Graphique d'évolution des moyennes">
<svg viewBox="0 0 {classDetail.evolution.length * 80} 200" class="chart-svg">
{#each [0, 0.25, 0.5, 0.75, 1] as ratio}
<line x1="0" y1={ratio * 180 + 10} x2={classDetail.evolution.length * 80} y2={ratio * 180 + 10} stroke="#e5e7eb" stroke-width="1"/>
<text x="0" y={ratio * 180 + 6} font-size="10" fill="#9ca3af">{(evoMaxY - ratio * evoRangeY).toFixed(1)}</text>
{/each}
<polyline
fill="none"
stroke="#3b82f6"
stroke-width="2.5"
points={classDetail.evolution.map((e, i) => `${i * 80 + 40},${chartY(e.average, evoMinY, evoRangeY)}`).join(' ')}
/>
{#each classDetail.evolution as e, i}
<circle cx={i * 80 + 40} cy={chartY(e.average, evoMinY, evoRangeY)} r="4" fill="#3b82f6"/>
<text x={i * 80 + 40} y={chartY(e.average, evoMinY, evoRangeY) - 10} text-anchor="middle" font-size="11" fill="#1e40af">{e.average.toFixed(1)}</text>
<text x={i * 80 + 40} y="198" text-anchor="middle" font-size="10" fill="#6b7280">{formatMonth(e.month)}</text>
{/each}
</svg>
</div>
</section>
{:else}
<section class="chart-section">
<h2>Évolution sur l'année</h2>
<p class="empty-section-text">Pas assez de données pour afficher l'évolution (minimum 2 mois).</p>
</section>
{/if}
<!-- Student List -->
<section class="chart-section">
<h2>Moyennes par élève</h2>
<div class="student-table-wrapper">
<table class="student-table">
<thead>
<tr>
<th>Élève</th>
<th>Moyenne</th>
<th>Statut</th>
<th>Tendance</th>
</tr>
</thead>
<tbody>
{#each classDetail.students as student}
<tr class:in-difficulty={student.inDifficulty}>
<td>
<button class="student-link" onclick={() => selectStudent(student)}>
{student.studentName}
</button>
</td>
<td class="text-center">{student.average != null ? student.average.toFixed(2) : '—'}</td>
<td class="text-center">
{#if student.inDifficulty}
<span class="badge badge-danger">En difficulté</span>
{:else}
<span class="badge badge-success">OK</span>
{/if}
</td>
<td class="text-center">
<span class="trend {trendClass(student.trend)}">{trendIcon(student.trend)}</span>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</section>
</div>
{/if}
{/if}
<!-- STUDENT PROGRESSION -->
{#if currentView === 'student-progression'}
{#if isLoadingProgression}
<div class="loading">Chargement de la progression...</div>
{:else if studentProgression && studentProgression.grades.length > 0}
<section class="chart-section">
<h2>Notes sur l'année</h2>
<div class="line-chart" role="img" aria-label="Graphique de progression de l'élève">
<svg viewBox="0 0 {progWidth} 220" class="chart-svg">
{#each [0, 0.25, 0.5, 0.75, 1] as ratio}
<line x1="0" y1={ratio * 180 + 10} x2={progWidth} y2={ratio * 180 + 10} stroke="#e5e7eb" stroke-width="1"/>
<text x="0" y={ratio * 180 + 6} font-size="10" fill="#9ca3af">{(progMaxY - ratio * progRangeY).toFixed(1)}</text>
{/each}
{#if studentProgression.trendLine}
<line
x1="40"
y1={chartY(studentProgression.trendLine.intercept + studentProgression.trendLine.slope, progMinY, progRangeY)}
x2={(progGrades.length - 1) * 80 + 40}
y2={chartY(studentProgression.trendLine.intercept + studentProgression.trendLine.slope * progGrades.length, progMinY, progRangeY)}
stroke="#ef4444" stroke-width="1.5" stroke-dasharray="6,4" opacity="0.7"
/>
{/if}
<polyline
fill="none"
stroke="#10b981"
stroke-width="2.5"
points={progGrades.map((g, i) => `${i * 80 + 40},${chartY(g.value, progMinY, progRangeY)}`).join(' ')}
/>
{#each progGrades as g, i}
<circle cx={i * 80 + 40} cy={chartY(g.value, progMinY, progRangeY)} r="4" fill="#10b981"/>
<text x={i * 80 + 40} y={chartY(g.value, progMinY, progRangeY) - 10} text-anchor="middle" font-size="11" fill="#065f46">{g.value.toFixed(1)}</text>
<text x={i * 80 + 40} y="210" text-anchor="middle" font-size="9" fill="#6b7280">{g.date.slice(5)}</text>
{/each}
</svg>
</div>
<div class="grade-legend">
{#each progGrades as g}
<div class="grade-legend-item">
<span class="legend-date">{g.date}</span>
<span class="legend-title">{g.evaluationTitle}</span>
<span class="legend-value">{g.value.toFixed(1)}/20</span>
</div>
{/each}
</div>
</section>
{:else}
<div class="empty-state">Aucune note publiée pour cet élève dans cette matière.</div>
{/if}
{/if}
</div>
<style>
.statistics-page {
max-width: 1200px;
margin: 0 auto;
padding: 1.5rem;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1.5rem;
gap: 1rem;
flex-wrap: wrap;
}
.header-left { display: flex; flex-direction: column; gap: 0.5rem; }
.page-header h1 {
font-size: 1.5rem;
font-weight: 700;
color: #1e293b;
margin: 0;
}
.back-link {
background: none;
border: none;
color: #3b82f6;
cursor: pointer;
font-size: 0.875rem;
padding: 0;
text-align: left;
}
.back-link:hover { text-decoration: underline; }
.header-actions { display: flex; gap: 0.5rem; }
.btn-secondary {
padding: 0.5rem 1rem;
background: white;
color: #374151;
border: 1px solid #d1d5db;
border-radius: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
}
.btn-secondary:hover { background: #f9fafb; }
.error-banner {
padding: 0.75rem 1rem;
background: #fef2f2;
border: 1px solid #fecaca;
border-radius: 0.5rem;
color: #dc2626;
margin-bottom: 1rem;
}
.loading {
text-align: center;
padding: 3rem;
color: #6b7280;
}
.empty-state {
text-align: center;
padding: 3rem;
color: #9ca3af;
background: #f9fafb;
border-radius: 0.75rem;
border: 1px dashed #d1d5db;
}
/* Overview Grid */
.overview-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
.class-card {
padding: 1.25rem;
background: white;
border: 1px solid #e5e7eb;
border-radius: 0.75rem;
cursor: pointer;
transition: box-shadow 0.2s, border-color 0.2s;
text-align: left;
width: 100%;
}
.class-card:hover {
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
border-color: #3b82f6;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.card-class { font-weight: 600; font-size: 1rem; color: #1e293b; }
.card-subject { font-size: 0.8125rem; color: #6b7280; }
.card-stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.5rem; }
.stat { display: flex; flex-direction: column; align-items: center; }
.stat-value { font-size: 1.25rem; font-weight: 700; color: #1e293b; }
.stat-label { font-size: 0.6875rem; color: #9ca3af; text-transform: uppercase; letter-spacing: 0.02em; }
/* Detail Layout */
.detail-layout { display: flex; flex-direction: column; gap: 1.5rem; }
.summary-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; }
.summary-card {
padding: 1.25rem;
background: white;
border: 1px solid #e5e7eb;
border-radius: 0.75rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
}
.summary-value { font-size: 2rem; font-weight: 700; color: #1e293b; }
.summary-label { font-size: 0.875rem; color: #6b7280; }
/* Chart Section */
.chart-section {
padding: 1.25rem;
background: white;
border: 1px solid #e5e7eb;
border-radius: 0.75rem;
}
.chart-section h2 {
font-size: 1rem;
font-weight: 600;
color: #374151;
margin: 0 0 1rem;
}
.empty-section-text { color: #9ca3af; font-size: 0.875rem; }
/* Histogram */
.histogram {
display: flex;
align-items: flex-end;
gap: 0.25rem;
height: 160px;
padding-top: 1rem;
}
.histogram-bar-wrapper {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
height: 100%;
justify-content: flex-end;
}
.histogram-bar {
width: 100%;
max-width: 48px;
background: #3b82f6;
border-radius: 4px 4px 0 0;
min-height: 2px;
position: relative;
transition: height 0.3s ease;
}
.bar-count {
position: absolute;
top: -18px;
left: 50%;
transform: translateX(-50%);
font-size: 0.75rem;
font-weight: 600;
color: #1e293b;
}
.bar-label {
font-size: 0.625rem;
color: #9ca3af;
margin-top: 0.25rem;
text-align: center;
}
/* Line Chart */
.line-chart { overflow-x: auto; }
.chart-svg { min-width: 400px; width: 100%; height: auto; }
/* Student Table */
.student-table-wrapper { overflow-x: auto; }
.student-table { width: 100%; border-collapse: collapse; font-size: 0.875rem; }
.student-table th {
text-align: left;
padding: 0.75rem 0.5rem;
font-weight: 600;
color: #6b7280;
border-bottom: 2px solid #e5e7eb;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.student-table td { padding: 0.75rem 0.5rem; border-bottom: 1px solid #f3f4f6; }
.student-table tr.in-difficulty { background: #fef2f2; }
.text-center { text-align: center; }
.student-link {
background: none;
border: none;
color: #3b82f6;
cursor: pointer;
font-size: 0.875rem;
padding: 0;
text-decoration: underline;
}
.badge {
display: inline-block;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-size: 0.75rem;
font-weight: 500;
}
.badge-danger { background: #fef2f2; color: #dc2626; }
.badge-success { background: #f0fdf4; color: #16a34a; }
.trend { font-size: 1.125rem; font-weight: 700; }
.trend-up { color: #16a34a; }
.trend-down { color: #dc2626; }
.trend-stable { color: #6b7280; }
/* Grade Legend */
.grade-legend {
margin-top: 1rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.grade-legend-item {
display: flex;
gap: 1rem;
font-size: 0.8125rem;
padding: 0.25rem 0;
border-bottom: 1px solid #f3f4f6;
}
.legend-date { color: #6b7280; min-width: 80px; }
.legend-title { flex: 1; color: #374151; }
.legend-value { font-weight: 600; color: #1e293b; }
/* Print */
@media print {
.page-header .header-actions { display: none; }
.back-link { display: none; }
.class-card { break-inside: avoid; }
.chart-section { break-inside: avoid; }
}
/* Mobile */
@media (max-width: 640px) {
.hide-mobile { display: none; }
.statistics-page { padding: 0.75rem; }
.page-header { flex-direction: column; gap: 0.75rem; }
.page-header h1 { font-size: 1.2rem; }
.header-actions { width: 100%; }
.header-actions .btn-secondary { flex: 1; padding: 0.625rem 0.5rem; font-size: 0.8125rem; }
.card-stats { grid-template-columns: repeat(2, 1fr); }
.overview-grid { grid-template-columns: 1fr; }
.summary-row { grid-template-columns: 1fr 1fr; }
.summary-value { font-size: 1.5rem; }
.summary-label { font-size: 0.75rem; }
.chart-section { padding: 1rem; }
.histogram { height: 120px; }
.bar-label { font-size: 0.5rem; }
.student-table { font-size: 0.8125rem; }
.student-table th { font-size: 0.6875rem; padding: 0.5rem 0.375rem; }
.student-table td { padding: 0.5rem 0.375rem; }
.student-table-wrapper { margin: 0 -1rem; padding: 0 1rem; }
/* Hide less useful columns on mobile */
.student-table th:nth-child(4),
.student-table td:nth-child(4) { display: none; }
}
</style>

View File

@@ -71,8 +71,16 @@
</td>
<td class="subdomain-cell">{establishment.subdomain}</td>
<td>
<span class="badge" class:active={establishment.status === 'active'}>
{establishment.status === 'active' ? 'Actif' : 'Inactif'}
<span
class="badge"
class:active={establishment.status === 'active'}
class:provisioning={establishment.status === 'provisioning'}
>
{establishment.status === 'active'
? 'Actif'
: establishment.status === 'provisioning'
? 'Provisioning…'
: 'Inactif'}
</span>
</td>
<td class="date-cell">
@@ -207,6 +215,11 @@
color: #16a34a;
}
.badge.provisioning {
background: #fef3c7;
color: #d97706;
}
.actions-cell {
white-space: nowrap;
}