feat: Désignation de remplaçants temporaires avec corrections sécurité
Permet aux administrateurs de désigner un enseignant remplaçant pour un autre enseignant absent, sur des classes et matières précises, pour une période donnée. Le dashboard enseignant affiche les remplacements actifs avec les noms de classes/matières au lieu des identifiants bruts. Inclut les corrections de la code review : - Requête findActiveByTenant qui excluait les remplacements en cours mais incluait les futurs (manquait start_date <= :at) - Validation tenant et rôle enseignant dans le handler de désignation pour empêcher l'affectation cross-tenant ou de non-enseignants - Validation structurée du payload classes (Assert\Collection + UUID) pour éviter les erreurs serveur sur payloads malformés - API replaced-classes enrichie avec les noms classe/matière
This commit is contained in:
@@ -46,6 +46,11 @@
|
||||
<span class="action-label">Affectations</span>
|
||||
<span class="action-hint">Enseignants et classes</span>
|
||||
</a>
|
||||
<a class="action-card" href="/admin/replacements">
|
||||
<span class="action-icon">🔄</span>
|
||||
<span class="action-label">Remplacements</span>
|
||||
<span class="action-hint">Enseignants absents</span>
|
||||
</a>
|
||||
<a class="action-card" href="/admin/academic-year/periods">
|
||||
<span class="action-icon">📅</span>
|
||||
<span class="action-label">Périodes scolaires</span>
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
<script lang="ts">
|
||||
import DashboardSection from '$lib/components/molecules/DashboardSection.svelte';
|
||||
import SkeletonList from '$lib/components/atoms/Skeleton/SkeletonList.svelte';
|
||||
import { getApiBaseUrl } from '$lib/api/config';
|
||||
import { authenticatedFetch, isAuthenticated } from '$lib/auth';
|
||||
import { untrack } from 'svelte';
|
||||
|
||||
let {
|
||||
isLoading = false,
|
||||
@@ -9,6 +12,53 @@
|
||||
isLoading?: boolean;
|
||||
hasRealData?: boolean;
|
||||
} = $props();
|
||||
|
||||
interface ReplacedClass {
|
||||
replacementId: string;
|
||||
replacedTeacherId: string;
|
||||
classId: string;
|
||||
subjectId: string;
|
||||
className: string;
|
||||
subjectName: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
}
|
||||
|
||||
let replacedClasses = $state<ReplacedClass[]>([]);
|
||||
let replacementsLoading = $state(false);
|
||||
|
||||
$effect(() => {
|
||||
untrack(() => {
|
||||
if (isAuthenticated()) {
|
||||
loadReplacedClasses();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
async function loadReplacedClasses() {
|
||||
try {
|
||||
replacementsLoading = true;
|
||||
const apiUrl = getApiBaseUrl();
|
||||
const response = await authenticatedFetch(`${apiUrl}/me/replaced-classes`);
|
||||
|
||||
if (!response.ok) return;
|
||||
|
||||
const data = await response.json();
|
||||
replacedClasses = Array.isArray(data) ? data : (data['hydra:member'] ?? []);
|
||||
} catch {
|
||||
// Silently fail - not critical for dashboard display
|
||||
} finally {
|
||||
replacementsLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function daysRemaining(endDate: string): number {
|
||||
const end = new Date(endDate);
|
||||
const now = new Date();
|
||||
now.setHours(0, 0, 0, 0);
|
||||
end.setHours(0, 0, 0, 0);
|
||||
return Math.ceil((end.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="dashboard-teacher">
|
||||
@@ -35,6 +85,44 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if replacedClasses.length > 0}
|
||||
<DashboardSection
|
||||
title="Classes en remplacement"
|
||||
subtitle="Vous remplacez actuellement un enseignant"
|
||||
>
|
||||
<div class="replacement-list">
|
||||
{#each replacedClasses as rc}
|
||||
{@const days = daysRemaining(rc.endDate)}
|
||||
<div class="replacement-card">
|
||||
<div class="replacement-badge">Remplacement</div>
|
||||
<div class="replacement-info">
|
||||
<span class="replacement-class">{rc.className}</span>
|
||||
<span class="replacement-subject">{rc.subjectName}</span>
|
||||
</div>
|
||||
<div class="replacement-dates">
|
||||
{new Date(rc.startDate).toLocaleDateString('fr-FR')}
|
||||
→
|
||||
{new Date(rc.endDate).toLocaleDateString('fr-FR')}
|
||||
<span class="replacement-countdown" class:urgent={days <= 3}>
|
||||
{#if days > 1}
|
||||
({days} jours restants)
|
||||
{:else if days === 1}
|
||||
(1 jour restant)
|
||||
{:else}
|
||||
(Dernier jour)
|
||||
{/if}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</DashboardSection>
|
||||
{:else if replacementsLoading}
|
||||
<DashboardSection title="Classes en remplacement">
|
||||
<SkeletonList items={2} message="Chargement des remplacements..." />
|
||||
</DashboardSection>
|
||||
{/if}
|
||||
|
||||
<div class="dashboard-grid">
|
||||
<DashboardSection
|
||||
title="Mes classes aujourd'hui"
|
||||
@@ -164,6 +252,62 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
/* Replacement section */
|
||||
.replacement-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.replacement-card {
|
||||
padding: 0.75rem 1rem;
|
||||
background: #eff6ff;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
.replacement-badge {
|
||||
display: inline-block;
|
||||
padding: 0.125rem 0.5rem;
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.replacement-info {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.replacement-class {
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.replacement-subject {
|
||||
color: #4b5563;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.replacement-dates {
|
||||
font-size: 0.8125rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.replacement-countdown {
|
||||
font-weight: 500;
|
||||
color: #16a34a;
|
||||
}
|
||||
|
||||
.replacement-countdown.urgent {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.dashboard-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
let isLoggingOut = $state(false);
|
||||
let accessChecked = $state(false);
|
||||
let hasAccess = $state(false);
|
||||
let mobileMenuOpen = $state(false);
|
||||
|
||||
const ADMIN_ROLES = [
|
||||
'ROLE_SUPER_ADMIN',
|
||||
@@ -18,6 +19,17 @@
|
||||
'ROLE_SECRETARIAT'
|
||||
];
|
||||
|
||||
const navLinks = [
|
||||
{ href: '/dashboard', label: 'Tableau de bord', isActive: () => false },
|
||||
{ href: '/admin/users', label: 'Utilisateurs', isActive: () => isUsersActive },
|
||||
{ href: '/admin/classes', label: 'Classes', isActive: () => isClassesActive },
|
||||
{ href: '/admin/subjects', label: 'Matières', isActive: () => isSubjectsActive },
|
||||
{ href: '/admin/assignments', label: 'Affectations', isActive: () => isAssignmentsActive },
|
||||
{ href: '/admin/replacements', label: 'Remplacements', isActive: () => isReplacementsActive },
|
||||
{ href: '/admin/academic-year/periods', label: 'Périodes', isActive: () => isPeriodsActive },
|
||||
{ href: '/admin/pedagogy', label: 'Pédagogie', isActive: () => isPedagogyActive }
|
||||
];
|
||||
|
||||
// Load user roles and verify admin access
|
||||
onMount(async () => {
|
||||
await fetchRoles();
|
||||
@@ -51,13 +63,65 @@
|
||||
goto('/settings');
|
||||
}
|
||||
|
||||
function toggleMobileMenu() {
|
||||
mobileMenuOpen = !mobileMenuOpen;
|
||||
}
|
||||
|
||||
function closeMobileMenu() {
|
||||
mobileMenuOpen = false;
|
||||
}
|
||||
|
||||
// Determine which admin section is active
|
||||
const isUsersActive = $derived(page.url.pathname.startsWith('/admin/users'));
|
||||
const isClassesActive = $derived(page.url.pathname.startsWith('/admin/classes'));
|
||||
const isSubjectsActive = $derived(page.url.pathname.startsWith('/admin/subjects'));
|
||||
const isPeriodsActive = $derived(page.url.pathname.startsWith('/admin/academic-year/periods'));
|
||||
const isAssignmentsActive = $derived(page.url.pathname.startsWith('/admin/assignments'));
|
||||
const isReplacementsActive = $derived(page.url.pathname.startsWith('/admin/replacements'));
|
||||
const isPedagogyActive = $derived(page.url.pathname.startsWith('/admin/pedagogy'));
|
||||
|
||||
const currentSectionLabel = $derived.by(() => {
|
||||
const path = page.url.pathname;
|
||||
for (const link of navLinks) {
|
||||
if (link.href !== '/dashboard' && path.startsWith(link.href)) {
|
||||
return link.label;
|
||||
}
|
||||
}
|
||||
return 'Administration';
|
||||
});
|
||||
|
||||
// Close menu on route change
|
||||
$effect(() => {
|
||||
void page.url.pathname;
|
||||
mobileMenuOpen = false;
|
||||
});
|
||||
|
||||
// Close menu on Escape key
|
||||
$effect(() => {
|
||||
if (!mobileMenuOpen) return;
|
||||
|
||||
function handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
mobileMenuOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', handleKeydown);
|
||||
return () => document.removeEventListener('keydown', handleKeydown);
|
||||
});
|
||||
|
||||
// Lock body scroll when menu is open
|
||||
$effect(() => {
|
||||
if (mobileMenuOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if !accessChecked}
|
||||
@@ -71,15 +135,25 @@
|
||||
<button class="logo-button" onclick={goHome}>
|
||||
<span class="logo-text">Classeo</span>
|
||||
</button>
|
||||
<nav class="header-nav">
|
||||
|
||||
<span class="mobile-section-label">{currentSectionLabel}</span>
|
||||
|
||||
<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">Tableau de bord</a>
|
||||
<a href="/admin/users" class="nav-link" class:active={isUsersActive}>Utilisateurs</a>
|
||||
<a href="/admin/classes" class="nav-link" class:active={isClassesActive}>Classes</a>
|
||||
<a href="/admin/subjects" class="nav-link" class:active={isSubjectsActive}>Matières</a>
|
||||
<a href="/admin/assignments" class="nav-link" class:active={isAssignmentsActive}>Affectations</a>
|
||||
<a href="/admin/academic-year/periods" class="nav-link" class:active={isPeriodsActive}>Périodes</a>
|
||||
<a href="/admin/pedagogy" class="nav-link" class:active={isPedagogyActive}>Pédagogie</a>
|
||||
{#each navLinks as link}
|
||||
<a href={link.href} class="nav-link" class:active={link.isActive()}>{link.label}</a>
|
||||
{/each}
|
||||
<button class="nav-button" onclick={goSettings}>Paramètres</button>
|
||||
<button class="logout-button" onclick={handleLogout} disabled={isLoggingOut}>
|
||||
{#if isLoggingOut}
|
||||
@@ -93,6 +167,60 @@
|
||||
</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">
|
||||
<span class="logo-text">Classeo</span>
|
||||
<button
|
||||
class="mobile-close"
|
||||
onclick={closeMobileMenu}
|
||||
aria-label="Fermer le menu"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
<div class="mobile-drawer-body">
|
||||
<div class="mobile-role-switcher">
|
||||
<RoleSwitcher />
|
||||
</div>
|
||||
{#each navLinks as link}
|
||||
<a
|
||||
href={link.href}
|
||||
class="mobile-nav-link"
|
||||
class:active={link.isActive()}
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="mobile-drawer-footer">
|
||||
<button class="mobile-nav-link" onclick={goSettings}>Paramètres</button>
|
||||
<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="admin-main">
|
||||
<div class="main-content">
|
||||
{@render children()}
|
||||
@@ -131,9 +259,8 @@
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
height: auto;
|
||||
padding: 0.75rem 0;
|
||||
height: 56px;
|
||||
padding: 0;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
@@ -150,23 +277,64 @@
|
||||
color: var(--accent-primary, #0ea5e9);
|
||||
}
|
||||
|
||||
.header-nav {
|
||||
/* Mobile section label */
|
||||
.mobile-section-label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1f2937);
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Hamburger button */
|
||||
.hamburger-button {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
gap: 5px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
border-radius: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.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;
|
||||
gap: 0.125rem;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
padding: 0.375rem 0.625rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #64748b);
|
||||
text-decoration: none;
|
||||
border-radius: 0.5rem;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
@@ -180,8 +348,8 @@
|
||||
}
|
||||
|
||||
.nav-button {
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
padding: 0.375rem 0.625rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #64748b);
|
||||
background: transparent;
|
||||
@@ -189,6 +357,8 @@
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.nav-button:hover {
|
||||
@@ -199,9 +369,9 @@
|
||||
.logout-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.625rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary, #64748b);
|
||||
background: transparent;
|
||||
@@ -209,6 +379,8 @@
|
||||
border-radius: 0.5rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.logout-button:hover:not(:disabled) {
|
||||
@@ -221,6 +393,118 @@
|
||||
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(320px, 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;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes slideInLeft {
|
||||
from { transform: translateX(-100%); }
|
||||
to { transform: translateX(0); }
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
@@ -246,18 +530,33 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
@media (min-width: 1200px) {
|
||||
.header-content {
|
||||
flex-wrap: nowrap;
|
||||
height: 64px;
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.header-nav {
|
||||
width: auto;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: flex-start;
|
||||
.mobile-section-label {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.hamburger-button {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.desktop-nav {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.nav-link,
|
||||
.nav-button {
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.logout-button {
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.admin-main {
|
||||
|
||||
@@ -1,173 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { getApiBaseUrl } from '$lib/api/config';
|
||||
import { authenticatedFetch } from '$lib/auth/auth.svelte';
|
||||
|
||||
let classCount = $state<number | null>(null);
|
||||
let subjectCount = $state<number | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
loadStats();
|
||||
});
|
||||
|
||||
async function loadStats() {
|
||||
const base = getApiBaseUrl();
|
||||
|
||||
const [classesRes, subjectsRes] = await Promise.allSettled([
|
||||
authenticatedFetch(`${base}/classes`),
|
||||
authenticatedFetch(`${base}/subjects`)
|
||||
]);
|
||||
|
||||
if (classesRes.status === 'fulfilled' && classesRes.value.ok) {
|
||||
const data = await classesRes.value.json();
|
||||
classCount = Array.isArray(data) ? data.length : (data['hydra:totalItems'] ?? null);
|
||||
}
|
||||
|
||||
if (subjectsRes.status === 'fulfilled' && subjectsRes.value.ok) {
|
||||
const data = await subjectsRes.value.json();
|
||||
subjectCount = Array.isArray(data) ? data.length : (data['hydra:totalItems'] ?? null);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Administration - Classeo</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="admin-dashboard">
|
||||
<header class="page-header">
|
||||
<h1>Administration</h1>
|
||||
<p class="subtitle">Configurez votre établissement</p>
|
||||
</header>
|
||||
|
||||
<div class="stats-row">
|
||||
<div class="stat-card">
|
||||
<span class="stat-value">{classCount ?? '–'}</span>
|
||||
<span class="stat-label">Classes</span>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<span class="stat-value">{subjectCount ?? '–'}</span>
|
||||
<span class="stat-label">Matières</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="action-cards">
|
||||
<a class="action-card" href="/admin/classes">
|
||||
<span class="action-icon">🏫</span>
|
||||
<span class="action-label">Classes</span>
|
||||
<span class="action-hint">Créer et gérer les classes</span>
|
||||
</a>
|
||||
<a class="action-card" href="/admin/subjects">
|
||||
<span class="action-icon">📚</span>
|
||||
<span class="action-label">Matières</span>
|
||||
<span class="action-hint">Créer et gérer les matières</span>
|
||||
</a>
|
||||
<a class="action-card" href="/admin/academic-year/periods">
|
||||
<span class="action-icon">📅</span>
|
||||
<span class="action-label">Périodes scolaires</span>
|
||||
<span class="action-hint">Trimestres ou semestres</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.admin-dashboard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1f2937);
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
margin: 0.25rem 0 0;
|
||||
color: var(--text-secondary, #64748b);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 1rem 1.5rem;
|
||||
background: var(--surface-elevated, #fff);
|
||||
border: 1px solid var(--border-subtle, #e2e8f0);
|
||||
border-radius: 0.75rem;
|
||||
min-width: 100px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary, #1f2937);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #64748b);
|
||||
}
|
||||
|
||||
.action-cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.action-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1.5rem 1rem;
|
||||
background: var(--surface-elevated, #fff);
|
||||
border: 2px solid var(--border-subtle, #e2e8f0);
|
||||
border-radius: 0.75rem;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.action-card:hover {
|
||||
border-color: var(--accent-primary, #0ea5e9);
|
||||
background: var(--accent-primary-light, #e0f2fe);
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.action-icon {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.action-label {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #374151);
|
||||
}
|
||||
|
||||
.action-hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.stats-row {
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
5
frontend/src/routes/admin/+page.ts
Normal file
5
frontend/src/routes/admin/+page.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
export function load() {
|
||||
redirect(302, '/admin/users');
|
||||
}
|
||||
@@ -352,11 +352,11 @@
|
||||
<tbody>
|
||||
{#each assignments as assignment (assignment.id)}
|
||||
<tr>
|
||||
<td class="teacher-cell">
|
||||
<td data-label="Enseignant" class="teacher-cell">
|
||||
<span class="teacher-name">{assignment.teacherFirstName} {assignment.teacherLastName}</span>
|
||||
</td>
|
||||
<td>{assignment.className}</td>
|
||||
<td>
|
||||
<td data-label="Classe">{assignment.className}</td>
|
||||
<td data-label="Matière">
|
||||
{#if getSubjectColor(assignment.subjectId)}
|
||||
<span
|
||||
class="subject-badge"
|
||||
@@ -368,13 +368,13 @@
|
||||
{assignment.subjectName}
|
||||
{/if}
|
||||
</td>
|
||||
<td>
|
||||
<td data-label="Statut">
|
||||
<span class="status-badge status-active">Active</span>
|
||||
</td>
|
||||
<td class="date-cell">
|
||||
<td data-label="Depuis le" class="date-cell">
|
||||
{new Date(assignment.startDate).toLocaleDateString('fr-FR')}
|
||||
</td>
|
||||
<td class="actions-cell">
|
||||
<td data-label="Actions" class="actions-cell">
|
||||
<button
|
||||
class="btn-remove"
|
||||
onclick={() => openDeleteModal(assignment)}
|
||||
@@ -916,12 +916,58 @@
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.assignments-table th:nth-child(4),
|
||||
.assignments-table td:nth-child(4),
|
||||
.assignments-table th:nth-child(5),
|
||||
.assignments-table td:nth-child(5) {
|
||||
display: table-cell;
|
||||
@media (max-width: 767px) {
|
||||
.table-container {
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.assignments-table thead {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.assignments-table tbody tr {
|
||||
display: block;
|
||||
background: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.assignments-table tr:hover td {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.assignments-table td {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
padding: 0.5rem 1rem;
|
||||
border-bottom: none;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.assignments-table td::before {
|
||||
content: attr(data-label);
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
text-align: left;
|
||||
margin-right: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.teacher-cell {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.actions-cell {
|
||||
justify-content: flex-end;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid #f3f4f6;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
1268
frontend/src/routes/admin/replacements/+page.svelte
Normal file
1268
frontend/src/routes/admin/replacements/+page.svelte
Normal file
File diff suppressed because it is too large
Load Diff
@@ -569,11 +569,11 @@
|
||||
<tbody>
|
||||
{#each users as user (user.id)}
|
||||
<tr>
|
||||
<td class="user-name-cell">
|
||||
<td data-label="Nom" class="user-name-cell">
|
||||
<span class="user-fullname">{user.firstName} {user.lastName}</span>
|
||||
</td>
|
||||
<td class="user-email">{user.email}</td>
|
||||
<td>
|
||||
<td data-label="Email" class="user-email">{user.email}</td>
|
||||
<td data-label="Rôle">
|
||||
<div class="role-badges">
|
||||
{#if user.roles && user.roles.length > 0}
|
||||
{#each user.roles as roleValue}
|
||||
@@ -584,7 +584,7 @@
|
||||
{/if}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<td data-label="Statut">
|
||||
<span class="status-badge {getStatutClass(user.statut, user.invitationExpiree)}">
|
||||
{getStatutDisplay(user.statut, user.invitationExpiree)}
|
||||
</span>
|
||||
@@ -594,8 +594,8 @@
|
||||
</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="date-cell">{formatDate(user.invitedAt)}</td>
|
||||
<td class="actions-cell">
|
||||
<td data-label="Invitation" class="date-cell">{formatDate(user.invitedAt)}</td>
|
||||
<td data-label="Actions" class="actions-cell">
|
||||
{#if user.id !== getCurrentUserId()}
|
||||
<button
|
||||
class="btn-secondary btn-sm"
|
||||
@@ -1196,11 +1196,6 @@
|
||||
background: #f9fafb;
|
||||
}
|
||||
|
||||
.users-table th:nth-child(5),
|
||||
.users-table td:nth-child(5) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.user-name-cell {
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -1481,10 +1476,61 @@
|
||||
.form-row {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.users-table th:nth-child(5),
|
||||
.users-table td:nth-child(5) {
|
||||
display: table-cell;
|
||||
@media (max-width: 767px) {
|
||||
.users-table-container {
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.users-table thead {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.users-table tbody tr {
|
||||
display: block;
|
||||
background: white;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.75rem;
|
||||
margin-bottom: 0.75rem;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.users-table tr:hover td {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.users-table td {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
padding: 0.5rem 1rem;
|
||||
border-bottom: none;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.users-table td::before {
|
||||
content: attr(data-label);
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
text-align: left;
|
||||
margin-right: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-name-cell {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.actions-cell {
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid #f3f4f6;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user