feat: Permettre aux parents de consulter l'emploi du temps de leurs enfants
Les parents avaient accès au lien "Emploi du temps" dans la navigation,
mais le dashboard n'affichait aucune donnée réelle : la section EDT
restait un placeholder vide ("L'emploi du temps sera disponible...").
Cette implémentation connecte le dashboard parent aux vrais endpoints API
(GET /api/me/children/{childId}/schedule/day|week/{date} et le résumé
multi-enfants), affiche le ScheduleWidget avec le prochain cours mis en
évidence (AC1), permet de cliquer sur chaque enfant dans le résumé pour
voir son EDT détaillé (AC2), et met en cache les endpoints parent dans le
Service Worker pour le mode offline (AC5).
Le handler backend est optimisé pour ne résoudre que l'enfant demandé
(via childId optionnel dans la query) au lieu de tous les enfants à chaque
appel, et les fonctions utilitaires dupliquées (formatSyncDate, timezone)
sont factorisées.
This commit is contained in:
@@ -1,11 +1,17 @@
|
||||
<script lang="ts">
|
||||
import type { DemoData } from '$types';
|
||||
import type { ScheduleSlot } from '$lib/features/schedule/api/schedule';
|
||||
import { fetchChildDaySchedule } from '$lib/features/schedule/api/parentSchedule';
|
||||
import { recordSync } from '$lib/features/schedule/stores/scheduleCache';
|
||||
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';
|
||||
import SkeletonCard from '$lib/components/atoms/Skeleton/SkeletonCard.svelte';
|
||||
import SkeletonList from '$lib/components/atoms/Skeleton/SkeletonList.svelte';
|
||||
import ScheduleWidget from '$lib/components/organisms/StudentSchedule/ScheduleWidget.svelte';
|
||||
import MultiChildSummary from '$lib/components/organisms/ParentSchedule/MultiChildSummary.svelte';
|
||||
import type { SerenityEmoji } from '$lib/features/dashboard/serenity-score';
|
||||
import { getActiveRole } from '$features/roles/roleContext.svelte';
|
||||
|
||||
let {
|
||||
demoData,
|
||||
@@ -14,6 +20,7 @@
|
||||
hasRealData = false,
|
||||
serenityEnabled = false,
|
||||
childName = '',
|
||||
selectedChildId = null,
|
||||
onToggleSerenity
|
||||
}: {
|
||||
demoData: DemoData;
|
||||
@@ -22,9 +29,53 @@
|
||||
hasRealData?: boolean;
|
||||
serenityEnabled?: boolean;
|
||||
childName?: string;
|
||||
selectedChildId?: string | null;
|
||||
onToggleSerenity?: (enabled: boolean) => void;
|
||||
} = $props();
|
||||
|
||||
let isParent = $derived(getActiveRole() === 'ROLE_PARENT');
|
||||
|
||||
// Schedule widget state — mirrors DashboardStudent pattern
|
||||
let scheduleSlots = $state<ScheduleSlot[]>([]);
|
||||
let scheduleNextSlotId = $state<string | null>(null);
|
||||
let scheduleLoading = $state(false);
|
||||
let scheduleError = $state<string | null>(null);
|
||||
|
||||
function formatLocalDate(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
async function loadChildSchedule(childId: string) {
|
||||
scheduleLoading = true;
|
||||
scheduleError = null;
|
||||
|
||||
try {
|
||||
const today = formatLocalDate(new Date());
|
||||
scheduleSlots = await fetchChildDaySchedule(childId, today);
|
||||
recordSync();
|
||||
|
||||
// Compute next slot
|
||||
const now = new Date();
|
||||
const currentTime = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
|
||||
const next = scheduleSlots.find((s) => s.date === today && s.startTime > currentTime);
|
||||
scheduleNextSlotId = next?.slotId ?? null;
|
||||
} catch (e) {
|
||||
scheduleError = e instanceof Error ? e.message : 'Erreur de chargement';
|
||||
} finally {
|
||||
scheduleLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Load schedule when selectedChildId changes
|
||||
$effect(() => {
|
||||
if (isParent && selectedChildId) {
|
||||
loadChildSchedule(selectedChildId);
|
||||
}
|
||||
});
|
||||
|
||||
let showExplainer = $state(false);
|
||||
|
||||
const isDemo = $derived(!hasRealData);
|
||||
@@ -74,11 +125,20 @@
|
||||
<!-- EDT Section -->
|
||||
<DashboardSection
|
||||
title="Emploi du temps"
|
||||
subtitle={hasRealData ? "Aujourd'hui" : undefined}
|
||||
isPlaceholder={!hasRealData}
|
||||
subtitle={isParent ? "Aujourd'hui" : (hasRealData ? "Aujourd'hui" : undefined)}
|
||||
isPlaceholder={!isParent && !hasRealData}
|
||||
placeholderMessage="L'emploi du temps sera disponible une fois les cours configurés"
|
||||
>
|
||||
{#if hasRealData}
|
||||
{#if isParent && selectedChildId}
|
||||
<ScheduleWidget
|
||||
slots={scheduleSlots}
|
||||
nextSlotId={scheduleNextSlotId}
|
||||
isLoading={scheduleLoading}
|
||||
error={scheduleError}
|
||||
/>
|
||||
{:else if isParent}
|
||||
<MultiChildSummary />
|
||||
{:else if hasRealData}
|
||||
{#if isLoading}
|
||||
<SkeletonList items={4} message={childName ? `Chargement de l'emploi du temps de ${childName}...` : "Chargement de l'emploi du temps..."} />
|
||||
{:else}
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
<script lang="ts">
|
||||
import type { ChildScheduleSummary } from '$lib/features/schedule/api/parentSchedule';
|
||||
import { fetchChildrenScheduleSummary } from '$lib/features/schedule/api/parentSchedule';
|
||||
import { formatSyncDate } from '$lib/features/schedule/formatSyncDate';
|
||||
import { isOffline, getLastSyncDate } from '$lib/features/schedule/stores/scheduleCache';
|
||||
import { untrack } from 'svelte';
|
||||
|
||||
let {
|
||||
onChildClick
|
||||
}: {
|
||||
onChildClick?: (childId: string) => void;
|
||||
} = $props();
|
||||
|
||||
let children = $state<ChildScheduleSummary[]>([]);
|
||||
let isLoading = $state(true);
|
||||
let error = $state<string | null>(null);
|
||||
let offline = $state(isOffline());
|
||||
let lastSync = $derived(getLastSyncDate());
|
||||
|
||||
$effect(() => {
|
||||
untrack(() => loadSummary());
|
||||
|
||||
function handleOnline() {
|
||||
offline = false;
|
||||
loadSummary();
|
||||
}
|
||||
function handleOffline() {
|
||||
offline = true;
|
||||
}
|
||||
|
||||
window.addEventListener('online', handleOnline);
|
||||
window.addEventListener('offline', handleOffline);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline);
|
||||
window.removeEventListener('offline', handleOffline);
|
||||
};
|
||||
});
|
||||
|
||||
async function loadSummary() {
|
||||
isLoading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
children = await fetchChildrenScheduleSummary();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Erreur de chargement';
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<div class="multi-child-summary">
|
||||
{#if offline}
|
||||
<div class="offline-banner" role="status">
|
||||
<span class="offline-dot"></span>
|
||||
<span>Hors ligne</span>
|
||||
{#if lastSync}
|
||||
<span class="sync-date">Dernière sync : {formatSyncDate(lastSync)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isLoading}
|
||||
<div class="loading">
|
||||
<div class="spinner"></div>
|
||||
<span>Chargement...</span>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="error">{error}</div>
|
||||
{:else if children.length === 0}
|
||||
<div class="empty">Aucun enfant trouvé</div>
|
||||
{:else}
|
||||
<div class="children-grid">
|
||||
{#each children as child (child.childId)}
|
||||
<button
|
||||
class="child-card"
|
||||
type="button"
|
||||
onclick={() => onChildClick?.(child.childId)}
|
||||
>
|
||||
<h4 class="child-name">{child.firstName} {child.lastName}</h4>
|
||||
|
||||
{#if child.nextClass}
|
||||
<div class="next-class">
|
||||
<span class="next-label">Prochain cours</span>
|
||||
<span class="next-subject">{child.nextClass.subjectName}</span>
|
||||
<span class="next-time">{child.nextClass.startTime} - {child.nextClass.endTime}</span>
|
||||
{#if child.nextClass.room}
|
||||
<span class="next-room">{child.nextClass.room}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{:else if child.todaySlots.length === 0}
|
||||
<p class="no-class">Pas de cours aujourd'hui</p>
|
||||
{:else}
|
||||
<p class="no-class">Plus de cours aujourd'hui</p>
|
||||
{/if}
|
||||
|
||||
{#if child.todaySlots.length > 0}
|
||||
<div class="today-count">
|
||||
{child.todaySlots.length} cours aujourd'hui
|
||||
</div>
|
||||
{/if}
|
||||
{#if onChildClick}
|
||||
<span class="detail-link">Voir le détail</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.multi-child-summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.offline-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: #fef3c7;
|
||||
border: 1px solid #fcd34d;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.offline-dot {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
background: #f59e0b;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sync-date {
|
||||
margin-left: auto;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 2rem;
|
||||
color: #6b7280;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-top-color: #3b82f6;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.error {
|
||||
padding: 1rem;
|
||||
color: #dc2626;
|
||||
font-size: 0.875rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: #9ca3af;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.children-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.children-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.child-card {
|
||||
padding: 1rem;
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 0.75rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
transition: border-color 0.15s, box-shadow 0.15s;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.child-card:hover {
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 1px 3px rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
|
||||
.child-name {
|
||||
margin: 0;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
}
|
||||
|
||||
.next-class {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.125rem;
|
||||
padding: 0.625rem;
|
||||
background: #eff6ff;
|
||||
border-left: 3px solid #3b82f6;
|
||||
border-radius: 0.375rem;
|
||||
}
|
||||
|
||||
.next-label {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
color: #3b82f6;
|
||||
}
|
||||
|
||||
.next-subject {
|
||||
font-weight: 500;
|
||||
color: #1f2937;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.next-time {
|
||||
font-size: 0.75rem;
|
||||
color: #3b82f6;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.next-room {
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.no-class {
|
||||
margin: 0;
|
||||
color: #9ca3af;
|
||||
font-size: 0.8125rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.today-count {
|
||||
font-size: 0.75rem;
|
||||
color: #6b7280;
|
||||
padding-top: 0.25rem;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.detail-link {
|
||||
font-size: 0.75rem;
|
||||
color: #3b82f6;
|
||||
font-weight: 500;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,377 @@
|
||||
<script lang="ts">
|
||||
import type { ScheduleSlot } from '$lib/features/schedule/api/schedule';
|
||||
import { fetchChildDaySchedule, fetchChildWeekSchedule } from '$lib/features/schedule/api/parentSchedule';
|
||||
import { untrack } from 'svelte';
|
||||
import { recordSync, isOffline, getLastSyncDate, prefetchScheduleDays } from '$lib/features/schedule/stores/scheduleCache';
|
||||
import { formatSyncDate } from '$lib/features/schedule/formatSyncDate';
|
||||
import ChildSelector from '$lib/components/organisms/ChildSelector/ChildSelector.svelte';
|
||||
import MultiChildSummary from '$lib/components/organisms/ParentSchedule/MultiChildSummary.svelte';
|
||||
import DayView from '$lib/components/organisms/StudentSchedule/DayView.svelte';
|
||||
import WeekView from '$lib/components/organisms/StudentSchedule/WeekView.svelte';
|
||||
import SlotDetails from '$lib/components/organisms/StudentSchedule/SlotDetails.svelte';
|
||||
|
||||
type ViewMode = 'day' | 'week';
|
||||
|
||||
let viewMode = $state<ViewMode>('day');
|
||||
let currentDate = $state(todayStr());
|
||||
let selectedChildId = $state<string | null>(null);
|
||||
let slots = $state<ScheduleSlot[]>([]);
|
||||
let nextSlotId = $state<string | null>(null);
|
||||
let isLoading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
let selectedSlot = $state<ScheduleSlot | null>(null);
|
||||
let offline = $state(isOffline());
|
||||
let lastSync = $derived(getLastSyncDate());
|
||||
|
||||
function formatLocalDate(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
|
||||
function todayStr(): string {
|
||||
return formatLocalDate(new Date());
|
||||
}
|
||||
|
||||
function mondayOfWeek(dateStr: string): string {
|
||||
const d = new Date(dateStr + 'T00:00:00');
|
||||
const day = d.getDay();
|
||||
const diff = d.getDate() - day + (day === 0 ? -6 : 1);
|
||||
d.setDate(diff);
|
||||
return formatLocalDate(d);
|
||||
}
|
||||
|
||||
async function loadSchedule() {
|
||||
if (!selectedChildId) return;
|
||||
|
||||
isLoading = true;
|
||||
error = null;
|
||||
|
||||
try {
|
||||
if (viewMode === 'day') {
|
||||
slots = await fetchChildDaySchedule(selectedChildId, currentDate);
|
||||
} else {
|
||||
slots = await fetchChildWeekSchedule(selectedChildId, currentDate);
|
||||
}
|
||||
recordSync();
|
||||
|
||||
// Compute next class from today's slots
|
||||
const today = todayStr();
|
||||
const d = new Date();
|
||||
const now = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
||||
const next = slots.find((s) => s.date === today && s.startTime > now);
|
||||
nextSlotId = next?.slotId ?? null;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Erreur de chargement';
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Online/offline listener
|
||||
$effect(() => {
|
||||
function handleOnline() {
|
||||
offline = false;
|
||||
loadSchedule();
|
||||
}
|
||||
function handleOffline() {
|
||||
offline = true;
|
||||
}
|
||||
|
||||
window.addEventListener('online', handleOnline);
|
||||
window.addEventListener('offline', handleOffline);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('online', handleOnline);
|
||||
window.removeEventListener('offline', handleOffline);
|
||||
};
|
||||
});
|
||||
|
||||
function handleChildSelected(childId: string) {
|
||||
selectedChildId = childId;
|
||||
untrack(() => {
|
||||
loadSchedule();
|
||||
// Prefetch for offline support
|
||||
prefetchScheduleDays((date) => fetchChildDaySchedule(childId, date));
|
||||
});
|
||||
}
|
||||
|
||||
function navigateDay(offset: number) {
|
||||
const d = new Date(currentDate + 'T00:00:00');
|
||||
d.setDate(d.getDate() + offset);
|
||||
currentDate = formatLocalDate(d);
|
||||
loadSchedule();
|
||||
}
|
||||
|
||||
function navigateWeek(offset: number) {
|
||||
const d = new Date(currentDate + 'T00:00:00');
|
||||
d.setDate(d.getDate() + offset * 7);
|
||||
currentDate = formatLocalDate(d);
|
||||
loadSchedule();
|
||||
}
|
||||
|
||||
function goToToday() {
|
||||
currentDate = todayStr();
|
||||
loadSchedule();
|
||||
}
|
||||
|
||||
function setViewMode(mode: ViewMode) {
|
||||
viewMode = mode;
|
||||
loadSchedule();
|
||||
}
|
||||
|
||||
// Swipe detection
|
||||
let touchStartX = $state(0);
|
||||
|
||||
function handleTouchStart(e: globalThis.TouchEvent) {
|
||||
const touch = e.touches[0];
|
||||
if (touch) touchStartX = touch.clientX;
|
||||
}
|
||||
|
||||
function handleTouchEnd(e: globalThis.TouchEvent) {
|
||||
const touch = e.changedTouches[0];
|
||||
if (!touch) return;
|
||||
const diff = touchStartX - touch.clientX;
|
||||
const threshold = 50;
|
||||
|
||||
if (Math.abs(diff) > threshold) {
|
||||
if (viewMode === 'day') {
|
||||
navigateDay(diff > 0 ? 1 : -1);
|
||||
} else {
|
||||
navigateWeek(diff > 0 ? 1 : -1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<div class="parent-schedule">
|
||||
<!-- Child selector -->
|
||||
<ChildSelector onChildSelected={handleChildSelected} />
|
||||
|
||||
<!-- Header bar -->
|
||||
<div class="schedule-header">
|
||||
<div class="view-toggle">
|
||||
<button class:active={viewMode === 'day'} aria-pressed={viewMode === 'day'} onclick={() => setViewMode('day')}>Jour</button>
|
||||
<button class:active={viewMode === 'week'} aria-pressed={viewMode === 'week'} onclick={() => setViewMode('week')}>Semaine</button>
|
||||
</div>
|
||||
|
||||
<div class="nav-controls">
|
||||
<button class="nav-btn" onclick={() => viewMode === 'day' ? navigateDay(-1) : navigateWeek(-1)} aria-label="Précédent">‹</button>
|
||||
<button class="today-btn" onclick={goToToday}>Aujourd'hui</button>
|
||||
<button class="nav-btn" onclick={() => viewMode === 'day' ? navigateDay(1) : navigateWeek(1)} aria-label="Suivant">›</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Offline indicator -->
|
||||
{#if offline}
|
||||
<div class="offline-banner" role="status">
|
||||
<span class="offline-dot"></span>
|
||||
<span>Hors ligne</span>
|
||||
{#if lastSync}
|
||||
<span class="sync-date">Dernière sync : {formatSyncDate(lastSync)}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Content area with swipe -->
|
||||
<div
|
||||
class="schedule-content"
|
||||
ontouchstart={handleTouchStart}
|
||||
ontouchend={handleTouchEnd}
|
||||
role="region"
|
||||
aria-label="Emploi du temps de l'enfant"
|
||||
>
|
||||
{#if !selectedChildId}
|
||||
<MultiChildSummary onChildClick={handleChildSelected} />
|
||||
{:else if isLoading}
|
||||
<div class="loading">
|
||||
<div class="spinner"></div>
|
||||
<span>Chargement...</span>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="error-state">
|
||||
<p>{error}</p>
|
||||
<button onclick={loadSchedule}>Réessayer</button>
|
||||
</div>
|
||||
{:else if viewMode === 'day'}
|
||||
<DayView
|
||||
{slots}
|
||||
date={currentDate}
|
||||
{nextSlotId}
|
||||
onSlotClick={(slot) => (selectedSlot = slot)}
|
||||
/>
|
||||
{:else}
|
||||
<WeekView
|
||||
{slots}
|
||||
weekStart={mondayOfWeek(currentDate)}
|
||||
onSlotClick={(slot) => (selectedSlot = slot)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Slot detail modal -->
|
||||
{#if selectedSlot}
|
||||
<SlotDetails slot={selectedSlot} onClose={() => (selectedSlot = null)} />
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.parent-schedule {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.schedule-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.view-toggle {
|
||||
display: flex;
|
||||
background: #f3f4f6;
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.25rem;
|
||||
}
|
||||
|
||||
.view-toggle button {
|
||||
padding: 0.375rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
color: #6b7280;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.view-toggle button.active {
|
||||
background: white;
|
||||
color: #1f2937;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.nav-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.nav-btn {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f3f4f6;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
font-size: 1.25rem;
|
||||
color: #374151;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.nav-btn:hover {
|
||||
background: #e5e7eb;
|
||||
}
|
||||
|
||||
.today-btn {
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
background: #eff6ff;
|
||||
color: #3b82f6;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.today-btn:hover {
|
||||
background: #dbeafe;
|
||||
}
|
||||
|
||||
.offline-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: #fef3c7;
|
||||
border: 1px solid #fcd34d;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: #92400e;
|
||||
}
|
||||
|
||||
.offline-dot {
|
||||
width: 0.5rem;
|
||||
height: 0.5rem;
|
||||
border-radius: 50%;
|
||||
background: #f59e0b;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.sync-date {
|
||||
margin-left: auto;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.schedule-content {
|
||||
min-height: 200px;
|
||||
touch-action: pan-y;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: #9ca3af;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
padding: 3rem;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border: 2px solid #e5e7eb;
|
||||
border-top-color: #3b82f6;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.error-state {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.error-state button {
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: #3b82f6;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 0.375rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -1,5 +1,6 @@
|
||||
<script lang="ts">
|
||||
import type { ScheduleSlot } from '$lib/features/schedule/api/schedule';
|
||||
import { formatSyncDate } from '$lib/features/schedule/formatSyncDate';
|
||||
import { isOffline, getLastSyncDate } from '$lib/features/schedule/stores/scheduleCache';
|
||||
|
||||
let {
|
||||
@@ -34,16 +35,6 @@
|
||||
};
|
||||
});
|
||||
|
||||
function formatSyncDate(iso: string | null): string {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString('fr-FR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="schedule-widget">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import type { ScheduleSlot } from '$lib/features/schedule/api/schedule';
|
||||
import { fetchDaySchedule, fetchWeekSchedule, fetchNextClass } from '$lib/features/schedule/api/schedule';
|
||||
import { formatSyncDate } from '$lib/features/schedule/formatSyncDate';
|
||||
import { untrack } from 'svelte';
|
||||
import { recordSync, isOffline, getLastSyncDate, prefetchScheduleDays } from '$lib/features/schedule/stores/scheduleCache';
|
||||
import DayView from './DayView.svelte';
|
||||
@@ -137,15 +138,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
function formatSyncDate(iso: string | null): string {
|
||||
if (!iso) return '';
|
||||
return new Date(iso).toLocaleString('fr-FR', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="student-schedule">
|
||||
|
||||
Reference in New Issue
Block a user