feat: Permettre aux élèves de consulter leur emploi du temps

Les élèves n'avaient aucun moyen de voir leur emploi du temps
depuis l'application. Cette fonctionnalité ajoute une page dédiée
avec deux modes de visualisation (jour et semaine), la navigation
temporelle, et le détail des cours au tap.

Le backend résout l'EDT de l'élève en chaînant : affectation classe →
créneaux récurrents + exceptions + calendrier scolaire → enrichissement
des noms (matières/enseignants). Le frontend utilise un cache offline
(Workbox NetworkFirst) pour rester consultable hors connexion.
This commit is contained in:
2026-03-05 16:21:37 +01:00
parent ae640e91ac
commit 36ceefb625
30 changed files with 3526 additions and 30 deletions

View File

@@ -1,14 +1,18 @@
<script lang="ts">
import { untrack } from 'svelte';
import { page } from '$app/state';
import { goto } from '$app/navigation';
import { isAuthenticated, refreshToken, logout } from '$lib/auth/auth.svelte';
import RoleSwitcher from '$lib/components/organisms/RoleSwitcher/RoleSwitcher.svelte';
import { fetchRoles, resetRoleContext } from '$features/roles/roleContext.svelte';
import { fetchRoles, resetRoleContext, getActiveRole } from '$features/roles/roleContext.svelte';
import { fetchBranding, resetBranding, getLogoUrl } from '$features/branding/brandingStore.svelte';
let { children } = $props();
let isLoggingOut = $state(false);
let mobileMenuOpen = $state(false);
let logoUrl = $derived(getLogoUrl());
let pathname = $derived(page.url.pathname);
let isEleve = $derived(getActiveRole() === 'ROLE_ELEVE');
// Load user roles on mount for multi-role context switching (FR5)
// Guard: only fetch if authenticated (or refresh succeeds), otherwise stay in demo mode
@@ -23,6 +27,20 @@
});
});
// Close menu on route change
$effect(() => {
void page.url.pathname;
mobileMenuOpen = false;
});
// Lock body scroll when mobile menu is open
$effect(() => {
document.body.style.overflow = mobileMenuOpen ? 'hidden' : '';
return () => {
document.body.style.overflow = '';
};
});
async function handleLogout() {
isLoggingOut = true;
try {
@@ -41,8 +59,24 @@
function goSettings() {
goto('/settings');
}
function toggleMobileMenu() {
mobileMenuOpen = !mobileMenuOpen;
}
function closeMobileMenu() {
mobileMenuOpen = false;
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape' && mobileMenuOpen) {
closeMobileMenu();
}
}
</script>
<svelte:window onkeydown={handleKeydown} />
<div class="dashboard-layout">
<header class="dashboard-header">
<div class="header-content">
@@ -52,22 +86,84 @@
{/if}
<span class="logo-text">Classeo</span>
</button>
<nav class="header-nav">
<button
class="hamburger-button"
onclick={toggleMobileMenu}
aria-expanded={mobileMenuOpen}
aria-label="Ouvrir le menu de navigation"
>
<span class="hamburger-line"></span>
<span class="hamburger-line"></span>
<span class="hamburger-line"></span>
</button>
<nav class="desktop-nav">
<RoleSwitcher />
<a href="/dashboard" class="nav-link active">Tableau de bord</a>
<button class="nav-button" onclick={goSettings}>Parametres</button>
<a href="/dashboard" class="nav-link" class:active={pathname === '/dashboard'}>Tableau de bord</a>
{#if isEleve}
<a href="/dashboard/schedule" class="nav-link" class:active={pathname === '/dashboard/schedule'}>Mon EDT</a>
{/if}
<button class="nav-button" onclick={goSettings}>Paramètres</button>
<button class="logout-button" onclick={handleLogout} disabled={isLoggingOut}>
{#if isLoggingOut}
<span class="spinner"></span>
Deconnexion...
Déconnexion...
{:else}
Deconnexion
Déconnexion
{/if}
</button>
</nav>
</div>
</header>
{#if mobileMenuOpen}
<div
class="mobile-overlay"
onclick={closeMobileMenu}
onkeydown={(e) => e.key === 'Enter' && closeMobileMenu()}
role="presentation"
></div>
<div class="mobile-drawer" role="dialog" aria-modal="true" aria-label="Menu de navigation">
<div class="mobile-drawer-header">
{#if logoUrl}
<img src={logoUrl} alt="Logo de l'établissement" class="header-logo" />
{/if}
<span class="logo-text">Classeo</span>
<button class="mobile-close" onclick={closeMobileMenu} aria-label="Fermer le menu">
&times;
</button>
</div>
<div class="mobile-drawer-body">
<div class="mobile-role-switcher">
<RoleSwitcher />
</div>
<a href="/dashboard" class="mobile-nav-link" class:active={pathname === '/dashboard'}>
Tableau de bord
</a>
{#if isEleve}
<a href="/dashboard/schedule" class="mobile-nav-link" class:active={pathname === '/dashboard/schedule'}>
Mon emploi du temps
</a>
{/if}
<button class="mobile-nav-link" onclick={goSettings}>Paramètres</button>
</div>
<div class="mobile-drawer-footer">
<button
class="mobile-nav-link mobile-logout"
onclick={handleLogout}
disabled={isLoggingOut}
>
{#if isLoggingOut}
Déconnexion...
{:else}
Déconnexion
{/if}
</button>
</div>
</div>
{/if}
<main class="dashboard-main">
<div class="main-content">
{@render children()}
@@ -86,7 +182,7 @@
.dashboard-header {
background: var(--surface-elevated, #fff);
border-bottom: 1px solid var(--border-subtle, #e2e8f0);
padding: 0 1.5rem;
padding: 0 1rem;
position: sticky;
top: 0;
z-index: 100;
@@ -98,10 +194,7 @@
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
height: auto;
padding: 0.75rem 0;
gap: 0.75rem;
height: 56px;
}
.logo-button {
@@ -127,12 +220,38 @@
color: var(--accent-primary, #0ea5e9);
}
.header-nav {
/* Hamburger — visible on mobile */
.hamburger-button {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 5px;
width: 40px;
height: 40px;
background: none;
border: none;
cursor: pointer;
padding: 8px;
border-radius: 0.5rem;
}
.hamburger-button:hover {
background: var(--surface-primary, #f8fafc);
}
.hamburger-line {
display: block;
width: 20px;
height: 2px;
background: var(--text-secondary, #64748b);
border-radius: 1px;
}
/* Desktop nav — hidden on mobile */
.desktop-nav {
display: none;
align-items: center;
width: 100%;
justify-content: flex-end;
flex-wrap: wrap;
gap: 0.5rem;
}
@@ -144,6 +263,7 @@
text-decoration: none;
border-radius: 0.5rem;
transition: all 0.2s;
white-space: nowrap;
}
.nav-link:hover {
@@ -166,6 +286,7 @@
border-radius: 0.5rem;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.nav-button:hover {
@@ -186,6 +307,7 @@
border-radius: 0.5rem;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.logout-button:hover:not(:disabled) {
@@ -198,6 +320,108 @@
cursor: not-allowed;
}
/* Mobile overlay */
.mobile-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.4);
z-index: 200;
animation: fadeIn 0.2s ease-out;
}
/* Mobile drawer */
.mobile-drawer {
position: fixed;
top: 0;
left: 0;
bottom: 0;
width: min(300px, 85vw);
background: var(--surface-elevated, #fff);
z-index: 201;
display: flex;
flex-direction: column;
animation: slideInLeft 0.25s ease-out;
}
.mobile-drawer-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 1rem 1.25rem;
border-bottom: 1px solid var(--border-subtle, #e2e8f0);
}
.mobile-close {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
background: none;
border: none;
cursor: pointer;
font-size: 1.5rem;
color: var(--text-secondary, #64748b);
border-radius: 0.5rem;
}
.mobile-close:hover {
background: var(--surface-primary, #f8fafc);
color: var(--text-primary, #1f2937);
}
.mobile-drawer-body {
flex: 1;
overflow-y: auto;
padding: 0.75rem 0;
}
.mobile-role-switcher {
padding: 0.5rem 1.25rem 0.75rem;
border-bottom: 1px solid var(--border-subtle, #e2e8f0);
margin-bottom: 0.5rem;
}
.mobile-nav-link {
display: flex;
align-items: center;
width: 100%;
padding: 0.75rem 1.25rem;
font-size: 0.9375rem;
font-weight: 500;
color: var(--text-secondary, #64748b);
text-decoration: none;
border: none;
background: none;
cursor: pointer;
border-left: 3px solid transparent;
transition: all 0.15s;
}
.mobile-nav-link:hover {
background: var(--surface-primary, #f8fafc);
color: var(--text-primary, #1f2937);
}
.mobile-nav-link.active {
color: var(--accent-primary, #0ea5e9);
border-left-color: var(--accent-primary, #0ea5e9);
background: var(--accent-primary-light, #e0f2fe);
}
.mobile-logout {
color: var(--color-alert, #ef4444);
}
.mobile-logout:hover {
background: #fef2f2;
}
.mobile-drawer-footer {
border-top: 1px solid var(--border-subtle, #e2e8f0);
padding: 0.5rem 0;
}
.spinner {
width: 14px;
height: 14px;
@@ -223,19 +447,44 @@
}
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slideInLeft {
from {
transform: translateX(-100%);
}
to {
transform: translateX(0);
}
}
@media (min-width: 768px) {
.header-content {
flex-wrap: nowrap;
height: 64px;
padding: 0;
gap: 0;
}
.header-nav {
width: auto;
flex-wrap: nowrap;
gap: 1rem;
justify-content: flex-start;
.hamburger-button {
display: none;
}
.desktop-nav {
display: flex;
}
.mobile-overlay,
.mobile-drawer {
display: none;
}
.dashboard-header {
padding: 0 1.5rem;
}
.dashboard-main {

View File

@@ -0,0 +1,29 @@
<script lang="ts">
import StudentSchedule from '$lib/components/organisms/StudentSchedule/StudentSchedule.svelte';
</script>
<svelte:head>
<title>Mon emploi du temps - Classeo</title>
</svelte:head>
<div class="schedule-page">
<header class="page-header">
<h1>Mon emploi du temps</h1>
</header>
<StudentSchedule />
</div>
<style>
.schedule-page {
display: flex;
flex-direction: column;
gap: 1rem;
}
.page-header h1 {
margin: 0;
font-size: 1.5rem;
font-weight: 700;
color: #1f2937;
}
</style>