diff --git a/src/app/components/AdminPanel.tsx b/src/app/components/AdminPanel.tsx index 2b24297..b39162c 100644 --- a/src/app/components/AdminPanel.tsx +++ b/src/app/components/AdminPanel.tsx @@ -1,24 +1,19 @@ /** - * AdminPanel — THI-225 Phase 9 Admin Panel v1 (skeleton). + * AdminPanel — THI-225 Phase 9 Admin Panel + THI-234 widgets data-driven. * * Route `/app/admin` gated par ``. * - * Scope v1 (deadline 10 juin 2026) — read-only super_admin only : - * - 4 widgets placeholder skeleton (data réelle en S3 Sprint 2.5) - * - Supabase health (users actifs, lessons completed, agrégats progress) - * - Sentry events (last 24h errors/warnings) - * - Application health (RLS policies status, RPC calls) - * - Student activity heatmap (THI-77, GitHub-style) + * Widgets LIVE (THI-234, migration 032 RPC SECURITY DEFINER + gate super_admin) : + * - Santé Supabase → `admin_platform_stats()` (users, actifs 7j, leçons 30j/total, top 5) + * - Activité élèves → `admin_activity_heatmap()` (progress/jour sur 52 semaines, THI-77) * - * Hors scope v1 (différé v2 post-deadline) : - * - `institution_admin` role (RLS scope creep P=70% identifié agent challenge) - * - Drains Vercel Analytics (Pro plan blocker confirmé spike 18/05) - * - Maintenance mode, screenshots upload moderation - * (in-app support tickets triage shipped Étape 4 — THI-320, see SupportTicketsSection) - * - Teacher adoption heatmap (THI-78) + * Widgets placeholder (différés v2 post-démo) : + * - Sentry events → nécessite route API + secret Sentry (plus lourd/risqué) + * - Application health → agrégats techniques (peu parlants pour la cible écoles) * - * Le lien vers le dashboard Vercel externe est placé en footer pour les - * admins TL eux-mêmes (les autres rôles n'arrivent jamais ici). + * Les agrégats sont cross-user mais gated `super_admin` côté RPC (raise + * PERMISSION_DENIED sinon) + REVOKE public/anon — defense in depth avec le + * RequireRole UI. Voir migration 032 + `useAdminAnalytics`. */ import { Helmet } from 'react-helmet-async'; import { Activity, AlertCircle, BarChart3, ExternalLink, ShieldCheck, Users } from 'lucide-react'; @@ -27,6 +22,8 @@ import { RequireRole } from './auth/RequireRole'; import { SupportTicketsSection } from './SupportTicketsSection'; import { Button } from './ui/button'; import { Card } from './ui/card'; +import { useAdminAnalytics, type PlatformStats, type HeatmapDay } from '@/lib/hooks/useAdminAnalytics'; +import { buildHeatmapGrid, heatmapLevel, heatmapMax, HEATMAP_WEEKS } from '@/lib/admin/heatmap'; export function AdminPanel() { return ( @@ -37,6 +34,8 @@ export function AdminPanel() { } function AdminPanelContent() { + const { stats, heatmap, loading, error } = useAdminAnalytics(); + return (
@@ -53,18 +52,15 @@ function AdminPanelContent() { Panneau administrateur

- Supervision et monitoring de la plateforme.{' '} - - Édition v1 — données live disponibles bientôt. - + Supervision et monitoring de la plateforme.

- + - +
@@ -92,7 +88,7 @@ interface WidgetCardProps { icon: React.ReactNode; title: string; description: string; - comingIn: string; + comingIn?: string; children?: React.ReactNode; } @@ -125,14 +121,96 @@ function WidgetCard({ icon, title, description, comingIn, children }: WidgetCard ); } -function WidgetSupabaseHealth() { +/** + * Shared body wrapper for the data-driven widgets — renders loading / error / + * empty states consistently, otherwise the children (the actual data view). + */ +function WidgetBody({ + loading, + error, + empty, + emptyLabel = 'Aucune donnée pour le moment.', + children, +}: { + loading: boolean; + error: Error | null; + empty: boolean; + emptyLabel?: string; + children: React.ReactNode; +}) { + if (loading) { + return

Chargement…

; + } + if (error) { + return ( +

+ Chargement impossible. Réessaie dans un instant. +

+ ); + } + if (empty) { + return

{emptyLabel}

; + } + return <>{children}; +} + +function Stat({ label, value }: { label: string; value: number }) { + return ( +
+
+ {value.toLocaleString('fr-FR')} +
+
{label}
+
+ ); +} + +function WidgetSupabaseHealth({ + stats, + loading, + error, +}: { + stats: PlatformStats | null; + loading: boolean; + error: Error | null; +}) { return ( } title="Santé Supabase" - description="Utilisateurs actifs (7j), leçons complétées (30j), modules populaires, sessions auth." - comingIn="Sprint S3 — requêtes Supabase REST sur progress + auth.users" - /> + description="Utilisateurs, activité récente et leçons complétées." + > + + {stats && ( +
+
+ + + + +
+ {stats.top_lessons.length > 0 && ( +
+

+ Top leçons +

+
    + {stats.top_lessons.map((l) => ( +
  • + {l.lesson_id} + {l.completions} +
  • + ))} +
+
+ )} +
+ )} +
+
); } @@ -142,7 +220,7 @@ function WidgetSentryEvents() { icon={} title="Événements Sentry" description="Erreurs et warnings des 24 dernières heures (free tier 5K events/mois)." - comingIn="Sprint S3 — Sentry REST API integration" + comingIn="Phase 9 v2 — Sentry REST API (route serveur + secret)" /> ); } @@ -153,37 +231,66 @@ function WidgetApplicationHealth() { icon={} title="Santé application" description="Status RLS policies, RPC calls réussis vs erreur, événements auth Supabase." - comingIn="Sprint S3 — Supabase Admin queries via supabase-js" + comingIn="Phase 9 v2 — Supabase Admin queries" /> ); } -function WidgetStudentHeatmap() { +// GitHub-style intensity palette (0 = aucune activité → 4 = jour le plus actif). +// emerald = couleur accent du projet (cohérent Sidebar / progress bar). +const HEATMAP_LEVEL_CLASS = [ + 'bg-[var(--github-bg)] border border-[var(--github-border-primary)]/40', + 'bg-emerald-900/70', + 'bg-emerald-700', + 'bg-emerald-500', + 'bg-emerald-400', +] as const; + +function WidgetStudentHeatmap({ + heatmap, + loading, + error, +}: { + heatmap: HeatmapDay[]; + loading: boolean; + error: Error | null; +}) { + const cells = buildHeatmapGrid(heatmap); + const max = heatmapMax(cells); + const totalCompletions = cells.reduce((sum, c) => sum + c.completions, 0); + return ( } title="Activité élèves" - description="Heatmap GitHub-style des leçons complétées par jour sur 52 semaines (THI-77)." - comingIn="Sprint S3 — agrégat progress par date_trunc('day', completed_at)" + description={`Leçons complétées par jour sur ${HEATMAP_WEEKS} semaines (THI-77).`} > - {/* Skeleton heatmap placeholder — emerald grid pattern */} -
- {Array.from({ length: 91 }, (_, i) => ( -
-

-

+ +
+
+ {cells.map((c) => ( +
+
+

+

+
); } diff --git a/src/app/components/SupportTicketsSection.tsx b/src/app/components/SupportTicketsSection.tsx index db528af..248551f 100644 --- a/src/app/components/SupportTicketsSection.tsx +++ b/src/app/components/SupportTicketsSection.tsx @@ -192,13 +192,14 @@ function TicketCard({ ticket, updating, onStatusChange }: TicketCardProps) { {/* Native onStatusChange(e.target.value as SupportTicketStatus)} - className="min-h-11 rounded-md border border-[var(--github-border-primary)] bg-[var(--github-bg)] px-2 py-1 text-sm text-[var(--github-text-primary)] font-mono [color-scheme:dark] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/60 disabled:opacity-50" + className="min-h-11 rounded-md border border-[var(--github-border-primary)] bg-[var(--github-bg)] px-2 py-1 text-base text-[var(--github-text-primary)] font-mono [color-scheme:dark] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-500/60 disabled:opacity-50" > {STATUS_ORDER.map((s) => (