feat: Permettre au parent de consulter les devoirs de ses enfants
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

Les parents avaient accès à l'emploi du temps de leurs enfants mais
pas à leurs devoirs. Sans cette visibilité, ils ne pouvaient pas
accompagner efficacement le travail scolaire à la maison, notamment
identifier les devoirs urgents ou contacter l'enseignant en cas
de besoin.

Le parent dispose désormais d'une vue consolidée multi-enfants avec
filtrage par enfant et par matière, badges d'urgence différenciés
(en retard / aujourd'hui / pour demain), lien de contact enseignant
pré-rempli, et cache offline scopé par utilisateur.
This commit is contained in:
2026-03-23 00:34:55 +01:00
parent 2e2328c6ca
commit 8d950b0f3c
18 changed files with 2689 additions and 13 deletions

View File

@@ -1,8 +1,12 @@
<script lang="ts">
import type { DemoData } from '$types';
import type { ScheduleSlot } from '$lib/features/schedule/api/schedule';
import type { StudentHomework, StudentHomeworkDetail } from '$lib/features/homework/api/studentHomework';
import { fetchChildDaySchedule } from '$lib/features/schedule/api/parentSchedule';
import type { ChildHomework } from '$lib/features/homework/api/parentHomework';
import { fetchChildrenHomework, fetchParentHomeworkDetail, getParentAttachmentUrl } from '$lib/features/homework/api/parentHomework';
import { recordSync } from '$lib/features/schedule/stores/scheduleCache.svelte';
import HomeworkDetail from '$lib/components/organisms/StudentHomework/HomeworkDetail.svelte';
import SerenityScorePreview from '$lib/components/molecules/SerenityScore/SerenityScorePreview.svelte';
import SerenityScoreExplainer from '$lib/components/molecules/SerenityScore/SerenityScoreExplainer.svelte';
import DashboardSection from '$lib/components/molecules/DashboardSection.svelte';
@@ -69,13 +73,73 @@
}
}
// Load schedule when selectedChildId changes
// Homework widget state
let childrenHomework = $state<ChildHomework[]>([]);
let homeworkLoading = $state(false);
interface HomeworkWithChild extends StudentHomework {
childFirstName: string;
}
let parentHomeworks = $derived<HomeworkWithChild[]>(
childrenHomework.flatMap((c) =>
c.homework.map((hw) => ({ ...hw, childFirstName: c.firstName }))
).sort((a, b) => a.dueDate.localeCompare(b.dueDate)).slice(0, 5)
);
async function loadHomeworks() {
homeworkLoading = true;
try {
childrenHomework = await fetchChildrenHomework();
} catch {
// Silently fail on dashboard widget
} finally {
homeworkLoading = false;
}
}
// Homework detail modal
let selectedHomeworkDetail = $state<StudentHomeworkDetail | null>(null);
async function openHomeworkDetail(homeworkId: string) {
try {
selectedHomeworkDetail = await fetchParentHomeworkDetail(homeworkId);
} catch {
window.location.href = '/dashboard/parent-homework';
}
}
function closeHomeworkDetail() {
selectedHomeworkDetail = null;
}
function handleOverlayClick(e: MouseEvent) {
if (e.target === e.currentTarget) closeHomeworkDetail();
}
function handleModalKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') closeHomeworkDetail();
}
function formatShortDate(dateStr: string): string {
const date = new Date(dateStr + 'T00:00:00');
return date.toLocaleDateString('fr-FR', { day: 'numeric', month: 'short' });
}
// Load schedule and homework when selectedChildId changes
$effect(() => {
if (isParent && selectedChildId) {
loadChildSchedule(selectedChildId);
}
});
$effect(() => {
if (isParent) {
loadHomeworks();
}
});
let showExplainer = $state(false);
const isDemo = $derived(!hasRealData);
@@ -181,10 +245,34 @@
<!-- Devoirs Section -->
<DashboardSection
title="Devoirs à venir"
isPlaceholder={!hasRealData}
isPlaceholder={!isParent && !hasRealData}
placeholderMessage="Les devoirs seront affichés ici une fois assignés"
>
{#if hasRealData}
{#if isParent}
{#if homeworkLoading}
<SkeletonList items={3} message="Chargement des devoirs..." />
{:else if parentHomeworks.length === 0}
<p class="empty-homework">Aucun devoir à venir</p>
{:else}
<ul class="homework-list">
{#each parentHomeworks as homework}
<li>
<button class="homework-item" style:border-left-color={homework.subjectColor ?? '#3b82f6'} onclick={() => openHomeworkDetail(homework.id)}>
<div class="homework-header">
<span class="homework-subject" style:color={homework.subjectColor ?? '#3b82f6'}>{homework.subjectName}</span>
<span class="homework-child">{homework.childFirstName}</span>
</div>
<span class="homework-title">{homework.title}</span>
<span class="homework-due">Pour le {formatShortDate(homework.dueDate)}</span>
</button>
</li>
{/each}
</ul>
<a href="/dashboard/parent-homework" class="view-all-link">
Voir tous les devoirs →
</a>
{/if}
{:else if hasRealData}
{#if isLoading}
<SkeletonList items={3} message="Chargement des devoirs..." />
{:else}
@@ -208,6 +296,16 @@
</div>
</div>
{#if selectedHomeworkDetail}
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div class="homework-modal-overlay" onclick={handleOverlayClick} onkeydown={handleModalKeydown} role="presentation">
<div class="homework-modal" role="dialog" aria-modal="true" aria-label="Détail du devoir">
<button class="homework-modal-close" onclick={closeHomeworkDetail} aria-label="Fermer">&times;</button>
<HomeworkDetail detail={selectedHomeworkDetail} onBack={closeHomeworkDetail} getAttachmentUrl={getParentAttachmentUrl} />
</div>
</div>
{/if}
{#if showExplainer}
<SerenityScoreExplainer
score={demoData.serenityScore}
@@ -416,4 +514,97 @@
background: #fee2e2;
color: #991b1b;
}
button.homework-item {
width: 100%;
text-align: left;
cursor: pointer;
border: none;
background: #f9fafb;
}
button.homework-item:hover {
background: #f3f4f6;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.homework-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.125rem;
}
.homework-child {
font-size: 0.75rem;
font-weight: 500;
color: #6b7280;
background: #f3f4f6;
padding: 0.0625rem 0.375rem;
border-radius: 0.25rem;
}
.view-all-link {
display: block;
text-align: center;
margin-top: 0.5rem;
padding: 0.5rem;
font-size: 0.875rem;
font-weight: 500;
color: #3b82f6;
text-decoration: none;
}
.view-all-link:hover {
color: #2563eb;
text-decoration: underline;
}
.empty-homework {
text-align: center;
padding: 1rem;
color: #6b7280;
font-size: 0.875rem;
}
/* Homework detail modal */
.homework-modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 50;
padding: 1rem;
}
.homework-modal {
position: relative;
background: white;
border-radius: 0.75rem;
padding: 1.5rem;
max-width: 40rem;
width: 100%;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
}
.homework-modal-close {
position: absolute;
top: 0.75rem;
right: 0.75rem;
background: none;
border: none;
font-size: 1.5rem;
color: #6b7280;
cursor: pointer;
line-height: 1;
padding: 0.25rem;
}
.homework-modal-close:hover {
color: #1f2937;
}
</style>

View File

@@ -0,0 +1,455 @@
<script lang="ts">
import type { ChildHomework } from '$lib/features/homework/api/parentHomework';
import type { StudentHomework, StudentHomeworkDetail as HomeworkDetailType } from '$lib/features/homework/api/studentHomework';
import { fetchChildrenHomework, fetchChildHomework, fetchParentHomeworkDetail, getParentAttachmentUrl } from '$lib/features/homework/api/parentHomework';
import { isOffline } from '$lib/utils/network';
import { cacheParentHomework, getCachedParentHomework } from '$lib/features/homework/stores/parentHomeworkCache.svelte';
import ChildSelector from '$lib/components/organisms/ChildSelector/ChildSelector.svelte';
import HomeworkDetail from '$lib/components/organisms/StudentHomework/HomeworkDetail.svelte';
import SkeletonList from '$lib/components/atoms/Skeleton/SkeletonList.svelte';
let childrenData = $state<ChildHomework[]>([]);
let selectedChildId = $state<string | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
let allSubjects = $state<{ id: string; name: string; color: string | null }[]>([]);
let selectedSubjectId = $state<string | null>(null);
let selectedDetail = $state<HomeworkDetailType | null>(null);
let detailLoading = $state(false);
let allHomework = $derived(
childrenData.flatMap((child) =>
child.homework.map((hw) => ({ ...hw, childName: `${child.firstName} ${child.lastName}` }))
)
);
function extractSubjects(hws: StudentHomework[]): { id: string; name: string; color: string | null }[] {
const map = new Map<string, { id: string; name: string; color: string | null }>();
for (const hw of hws) {
if (!map.has(hw.subjectId)) {
map.set(hw.subjectId, { id: hw.subjectId, name: hw.subjectName, color: hw.subjectColor });
}
}
return Array.from(map.values());
}
type UrgencyLevel = 'overdue' | 'today' | 'tomorrow';
function urgencyLevel(dueDate: string): UrgencyLevel | null {
const now = new Date();
const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
const tom = new Date(now);
tom.setDate(tom.getDate() + 1);
const tomorrowStr = `${tom.getFullYear()}-${String(tom.getMonth() + 1).padStart(2, '0')}-${String(tom.getDate()).padStart(2, '0')}`;
if (dueDate < todayStr) return 'overdue';
if (dueDate === todayStr) return 'today';
if (dueDate === tomorrowStr) return 'tomorrow';
return null;
}
function urgencyLabel(level: UrgencyLevel): string {
switch (level) {
case 'overdue': return 'En retard';
case 'today': return "Aujourd'hui";
case 'tomorrow': return 'Pour demain';
}
}
function formatDueDate(dateStr: string): string {
const date = new Date(dateStr + 'T00:00:00');
return date.toLocaleDateString('fr-FR', {
weekday: 'short',
day: 'numeric',
month: 'long'
});
}
async function loadHomework() {
loading = true;
error = null;
try {
if (selectedChildId) {
const child = await fetchChildHomework(selectedChildId, selectedSubjectId ?? undefined);
childrenData = [child];
} else {
childrenData = await fetchChildrenHomework(selectedSubjectId ?? undefined);
}
cacheParentHomework(childrenData);
if (selectedSubjectId === null) {
const all = childrenData.flatMap((c) => c.homework);
allSubjects = extractSubjects(all);
}
} catch (e) {
const cached = getCachedParentHomework();
if (cached) {
childrenData = cached;
if (selectedSubjectId === null) {
allSubjects = extractSubjects(cached.flatMap((c) => c.homework));
}
} else {
error = e instanceof Error ? e.message : 'Erreur de chargement';
}
} finally {
loading = false;
}
}
function handleChildSelected(childId: string | null) {
selectedChildId = childId;
selectedSubjectId = null;
loadHomework();
}
function handleFilterChange(subjectId: string | null) {
selectedSubjectId = subjectId;
loadHomework();
}
async function handleCardClick(homeworkId: string) {
detailLoading = true;
try {
selectedDetail = await fetchParentHomeworkDetail(homeworkId);
} catch {
// Stay on list if detail fails
} finally {
detailLoading = false;
}
}
function handleBack() {
selectedDetail = null;
}
$effect(() => {
loadHomework();
});
</script>
{#snippet homeworkCard(hw: StudentHomework)}
{@const level = urgencyLevel(hw.dueDate)}
<div
class="homework-card"
class:urgent={level !== null}
data-testid="homework-card"
style:border-left-color={hw.subjectColor ?? '#3b82f6'}
role="button"
tabindex="0"
onclick={() => handleCardClick(hw.id)}
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handleCardClick(hw.id); } }}
>
<div class="card-header">
<span class="subject-name" style:color={hw.subjectColor ?? '#3b82f6'}>{hw.subjectName}</span>
{#if level}
<span class="urgent-badge" class:overdue={level === 'overdue'} data-testid="urgent-badge">
{urgencyLabel(level)}
</span>
{/if}
</div>
<h4 class="card-title">{hw.title}</h4>
<div class="card-footer">
<span class="due-date">Pour le {formatDueDate(hw.dueDate)}</span>
{#if hw.hasAttachments}
<span class="attachment-indicator" title="Pièce(s) jointe(s)">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<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"/>
</svg>
</span>
{/if}
{#if level}
<a
href="/messages/new?to={hw.teacherId}&subject={encodeURIComponent('Devoir: ' + hw.title)}"
class="contact-teacher"
data-testid="contact-teacher"
onclick={(e) => e.stopPropagation()}
>
Contacter l'enseignant
</a>
{/if}
</div>
</div>
{/snippet}
{#if selectedDetail}
<HomeworkDetail detail={selectedDetail} onBack={handleBack} getAttachmentUrl={getParentAttachmentUrl} />
{:else}
<div class="parent-homework">
<ChildSelector onChildSelected={handleChildSelected} />
{#if isOffline()}
<div class="offline-banner" role="status">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="1" y1="1" x2="23" y2="23"/>
<path d="M16.72 11.06A10.94 10.94 0 0 1 19 12.55"/>
<path d="M5 12.55a10.94 10.94 0 0 1 5.17-2.39"/>
<path d="M10.71 5.05A16 16 0 0 1 22.56 9"/>
<path d="M1.42 9a15.91 15.91 0 0 1 4.7-2.88"/>
<path d="M8.53 16.11a6 6 0 0 1 6.95 0"/>
<line x1="12" y1="20" x2="12.01" y2="20"/>
</svg>
Mode hors ligne
</div>
{/if}
{#if allSubjects.length > 1}
<div class="filter-bar" role="toolbar" aria-label="Filtrer par matière">
<button
class="filter-chip"
class:active={selectedSubjectId === null}
onclick={() => handleFilterChange(null)}
>
Tous
</button>
{#each allSubjects as subject}
<button
class="filter-chip"
class:active={selectedSubjectId === subject.id}
style:--chip-color={subject.color ?? '#3b82f6'}
onclick={() => handleFilterChange(subject.id)}
>
{subject.name}
</button>
{/each}
</div>
{/if}
{#if loading}
<SkeletonList items={4} message="Chargement des devoirs..." />
{:else if error}
<div class="error-message" role="alert">
<p>{error}</p>
<button onclick={() => void loadHomework()}>Réessayer</button>
</div>
{:else if allHomework.length === 0}
<div class="empty-state">
<p>Aucun devoir pour le moment</p>
</div>
{:else}
{#if childrenData.length > 1}
{#each childrenData as child (child.childId)}
{#if child.homework.length > 0}
<section class="child-section">
<h3 class="child-name" data-testid="child-name">{child.firstName} {child.lastName}</h3>
<ul class="homework-list" role="list">
{#each child.homework as hw (hw.id)}
<li>{@render homeworkCard(hw)}</li>
{/each}
</ul>
</section>
{/if}
{/each}
{:else}
<ul class="homework-list" role="list">
{#each allHomework as hw (hw.id)}
<li>{@render homeworkCard(hw)}</li>
{/each}
</ul>
{/if}
{/if}
{#if detailLoading}
<div class="detail-loading-overlay" role="status">
<p>Chargement...</p>
</div>
{/if}
</div>
{/if}
<style>
.parent-homework {
display: flex;
flex-direction: column;
gap: 1rem;
}
.offline-banner {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.75rem;
background: #fef3c7;
border: 1px solid #f59e0b;
border-radius: 0.5rem;
color: #92400e;
font-size: 0.8125rem;
font-weight: 500;
}
.filter-bar {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.filter-chip {
padding: 0.375rem 0.75rem;
border: 1px solid #e5e7eb;
border-radius: 1rem;
background: white;
font-size: 0.8125rem;
cursor: pointer;
transition: background 0.15s, border-color 0.15s;
}
.filter-chip:hover {
background: #f3f4f6;
}
.filter-chip.active {
background: var(--chip-color, #3b82f6);
color: white;
border-color: var(--chip-color, #3b82f6);
}
.child-section {
margin-bottom: 0.5rem;
}
.child-name {
margin: 0 0 0.5rem;
font-size: 1rem;
font-weight: 600;
color: #1e40af;
}
.homework-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.homework-card {
padding: 0.75rem;
background: #f9fafb;
border-radius: 0.5rem;
border-left: 3px solid #3b82f6;
cursor: pointer;
transition: background 0.15s, box-shadow 0.15s;
}
.homework-card:hover {
background: #f3f4f6;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.homework-card:focus-visible {
outline: 2px solid #3b82f6;
outline-offset: 2px;
}
.homework-card.urgent {
border-left-color: #ef4444;
background: #fef2f2;
}
.homework-card.urgent:hover {
background: #fee2e2;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.25rem;
}
.subject-name {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
}
.urgent-badge {
font-size: 0.6875rem;
font-weight: 700;
color: #991b1b;
background: #fecaca;
border-radius: 999px;
padding: 0.125rem 0.5rem;
text-transform: uppercase;
}
.urgent-badge.overdue {
color: #7f1d1d;
background: #fca5a5;
}
.card-title {
margin: 0 0 0.25rem;
font-size: 0.9375rem;
font-weight: 500;
color: #1f2937;
}
.card-footer {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.due-date {
font-size: 0.8125rem;
color: #6b7280;
}
.attachment-indicator {
color: #6b7280;
display: flex;
align-items: center;
}
.contact-teacher {
margin-left: auto;
font-size: 0.75rem;
font-weight: 600;
color: #3b82f6;
text-decoration: none;
padding: 0.25rem 0.5rem;
border: 1px solid #bfdbfe;
border-radius: 0.375rem;
background: white;
transition: background 0.15s;
}
.contact-teacher:hover {
background: #eff6ff;
}
.empty-state {
text-align: center;
padding: 2rem;
color: #6b7280;
}
.error-message {
text-align: center;
padding: 1rem;
color: #ef4444;
}
.error-message button {
margin-top: 0.5rem;
padding: 0.375rem 0.75rem;
border: 1px solid #ef4444;
border-radius: 0.375rem;
background: white;
color: #ef4444;
cursor: pointer;
}
.detail-loading-overlay {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.8);
z-index: 50;
}
</style>

View File

@@ -1,14 +1,16 @@
<script lang="ts">
import type { StudentHomeworkDetail } from '$lib/features/homework/api/studentHomework';
import { getAttachmentUrl } from '$lib/features/homework/api/studentHomework';
import { getAttachmentUrl as defaultGetAttachmentUrl } from '$lib/features/homework/api/studentHomework';
import { authenticatedFetch } from '$lib/auth';
let {
detail,
onBack
onBack,
getAttachmentUrl = defaultGetAttachmentUrl
}: {
detail: StudentHomeworkDetail;
onBack: () => void;
getAttachmentUrl?: (homeworkId: string, attachmentId: string) => string;
} = $props();
function formatDueDate(dateStr: string): string {