J’ai refait tout le pack en React JS (sans TypeScript) dans le canvas : composant Dashboard42Eleve.jsx + routes Next.js (/api/42/*, /api/odoo/*) et helpers Odoo JSON-RPC. Il ne vous reste qu’à :

  1. Renseigner .env.local (variables en tête du fichier).
  2. Vérifier NEXT_PUBLIC_BASE_URL (ex. http://localhost:3000).
  3. Avoir shadcn/ui + lucide-react installés et résolus sous @/components/ui/*.

Vous voulez une variante CRA/Vite (React pur, sans Next.js) ou je vous ajoute un SWR cache et un thème sombre aligné Intra 42 ?

Dialogue GPT  ayant généré ce code en find e la page ci dessous 

https://chatgpt.com/share/68c6b7cb-cb90-8005-9a3c-dd4663f5eb89




// ============================================================================

// React JS (sans TypeScript) — Pack complet prêt à coller

// Dossiers/chemins proposés pour un projet Next.js App Router

// - .env.local (variables)

// - /app/api/42/token/route.js

// - /app/api/42/me/route.js

// - /app/api/42/dashboard/route.js

// - /app/api/odoo/rpc.js

// - /app/api/odoo/agenda/route.js

// - /components/Dashboard42Eleve.jsx (React, shadcn/ui, lucide-react)

// ============================================================================


// ---------------------------

// .env.local (exemple)

// ---------------------------

// FORTY_TWO_CLIENT_ID=...

// FORTY_TWO_CLIENT_SECRET=...

// ODOO_URL=https://votre-instance.odoo.com

// ODOO_DB=nom_de_bdd

// ODOO_USER=admin@domaine.tld

// ODOO_PASSWORD=...

// NEXT_PUBLIC_BASE_URL=http://localhost:3000


// ---------------------------

// /app/api/42/token/route.js

// ---------------------------

import { NextResponse } from "next/server";


export async function POST() {

  const body = new URLSearchParams({

    grant_type: "client_credentials",

    client_id: process.env.FORTY_TWO_CLIENT_ID,

    client_secret: process.env.FORTY_TWO_CLIENT_SECRET,

  });

  const r = await fetch("https://api.intra.42.fr/oauth/token", {

    method: "POST",

    headers: { "Content-Type": "application/x-www-form-urlencoded" },

    body,

    cache: "no-store",

  });

  if (!r.ok) return NextResponse.json({ error: "42 auth failed" }, { status: r.status });

  const token = await r.json();

  return NextResponse.json(token, { headers: { "Cache-Control": "no-store" } });

}


// ---------------------------

// /app/api/42/me/route.js

// ---------------------------

import { NextResponse as NextResponse_me } from "next/server";


async function token42_me() {

  const base = process.env.NEXT_PUBLIC_BASE_URL || "";

  const r = await fetch(`${base}/api/42/token`, { method: "POST", cache: "no-store" });

  const t = await r.json();

  return t.access_token;

}


export async function GET() {

  const access = await token42_me();

  const r = await fetch("https://api.intra.42.fr/v2/me", {

    headers: { Authorization: `Bearer ${access}` },

    cache: "no-store",

  });

  const me = await r.json();

  return NextResponse_me.json(me, { headers: { "Cache-Control": "no-store" } });

}


// ---------------------------

// /app/api/42/dashboard/route.js

// ---------------------------

import { NextResponse as NextResponse_dash } from "next/server";


async function token42_dash() {

  const base = process.env.NEXT_PUBLIC_BASE_URL || "";

  const r = await fetch(`${base}/api/42/token`, { method: "POST", cache: "no-store" });

  const t = await r.json();

  return t.access_token;

}


async function getAll(url, access) {

  const out = [];

  let page = 1;

  for (;;) {

    const r = await fetch(`${url}${url.includes("?") ? "&" : "?"}page=${page}&per_page=100`, {

      headers: { Authorization: `Bearer ${access}` },

      cache: "no-store",

    });

    if (!r.ok) break;

    const arr = await r.json();

    out.push(...arr);

    if (!Array.isArray(arr) || arr.length < 100) break;

    page++;

  }

  return out;

}


export async function GET() {

  const access = await token42_dash();


  const meR = await fetch("https://api.intra.42.fr/v2/me", {

    headers: { Authorization: `Bearer ${access}` },

    cache: "no-store",

  });

  const me = await meR.json();

  const userId = me?.id;


  const cursus = await getAll(`https://api.intra.42.fr/v2/users/${userId}/cursus_users`, access);

  const current = cursus.find((c) => !c.end_at) || cursus[0] || {};

  const blackholed_at = current.blackholed_at || null;

  const enterDate = current.begin_at || null;


  const projects = await getAll(`https://api.intra.42.fr/v2/users/${userId}/projects_users`, access);


  const correctionsAssigned = await getAll(

    `https://api.intra.42.fr/v2/users/${userId}/scale_teams/as_corrector`,

    access

  );

  const correctionsPending = await getAll(

    `https://api.intra.42.fr/v2/users/${userId}/scale_teams/as_corrected`,

    access

  );


  const campus = await getAll(`https://api.intra.42.fr/v2/users/${userId}/campus`, access);


  return NextResponse_dash.json(

    {

      me,

      blackHole: { enterDate, blackholed_at },

      projects,

      corrections: { aFaire: correctionsAssigned, aRecevoir: correctionsPending },

      campus,

    },

    { headers: { "Cache-Control": "no-store" } }

  );

}


// ---------------------------

// /app/api/odoo/rpc.js

// ---------------------------

export async function odooCall(service, method, args) {

  const r = await fetch(`${process.env.ODOO_URL}/jsonrpc`, {

    method: "POST",

    headers: { "Content-Type": "application/json" },

    cache: "no-store",

    body: JSON.stringify({

      jsonrpc: "2.0",

      method: "call",

      params: { service, method, args },

      id: Date.now(),

    }),

  });

  const j = await r.json();

  if (j.error) throw new Error(j.error.data?.message || "Odoo RPC error");

  return j.result;

}


export async function odooAuth() {

  return odooCall("common", "authenticate", [

    process.env.ODOO_DB,

    process.env.ODOO_USER,

    process.env.ODOO_PASSWORD,

    {},

  ]);

}


export async function odooSearchRead(model, domain, fields, limit = 50) {

  const uid = await odooAuth();

  const kwargs = { fields, limit };

  return odooCall("object", "execute_kw", [

    process.env.ODOO_DB,

    uid,

    process.env.ODOO_PASSWORD,

    model,

    "search_read",

    [domain],

    kwargs,

  ]);

}


// ---------------------------

// /app/api/odoo/agenda/route.js

// ---------------------------

import { NextResponse as NextResponse_odoo } from "next/server";

import { odooSearchRead } from "../../odoo/rpc";


export async function GET() {

  const events = await odooSearchRead(

    "calendar.event",

    [["start", ">=", new Date().toISOString()]],

    ["name", "start", "stop", "location", "allday"],

    20

  );

  return NextResponse_odoo.json(events, { headers: { "Cache-Control": "no-store" } });

}


// ---------------------------

// /components/Dashboard42Eleve.jsx

// ---------------------------

import React, { useEffect, useState } from "react";

import { motion } from "framer-motion";

import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";

import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";

import { Badge } from "@/components/ui/badge";

import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";

import { Button } from "@/components/ui/button";

import { Progress } from "@/components/ui/progress";

import { Separator } from "@/components/ui/separator";

import {

  CalendarDays,

  Clock,

  Coins,

  MapPin,

  TerminalSquare,

  Wallet2,

  SunMoon,

  Zap,

  TimerReset,

  UserCheck,

  GitBranch,

  CheckCircle2,

  ArrowRight,

} from "lucide-react";


export default function Dashboard42Eleve() {

  const [loading, setLoading] = useState(true);

  const [data42, setData42] = useState(null);

  const [agenda, setAgenda] = useState([]);

  const [dark, setDark] = useState(false);


  useEffect(() => {

    (async () => {

      try {

        const r = await fetch("/api/42/dashboard", { cache: "no-store" });

        const j = await r.json();

        setData42(toUiDashboard(j));

      } catch (e) {

        console.error(e);

      }

      try {

        const r2 = await fetch("/api/odoo/agenda", { cache: "no-store" });

        const ev = await r2.json();

        setAgenda(toUiAgenda(ev));

      } catch (e) {

        console.error(e);

      }

      setLoading(false);

    })();

  }, []);


  if (loading) return <div className="p-6 text-sm text-muted-foreground">Chargement…</div>;

  if (!data42) return <div className="p-6 text-sm text-muted-foreground">Aucune donnée 42 disponible.</div>;


  const { me, projects, blackHole, corrections } = data42;


  return (

    <div className={(dark ? "dark " : "") + "min-h-screen bg-background text-foreground p-4 md:p-6 space-y-4"}>

      {/* Header */}

      <header className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">

        <div className="flex items-center gap-3">

          <Avatar className="h-12 w-12">

            <AvatarImage src={(me && me.image_url) || "https://i.pravatar.cc/96"} alt="user" />

            <AvatarFallback>{((me && me.login) || "JH").slice(0, 2).toUpperCase()}</AvatarFallback>

          </Avatar>

          <div>

            <div className="text-lg font-semibold">{(me && me.login) || "Cadet"} • Cursus 42</div>

            <div className="flex items-center gap-2 text-xs text-muted-foreground">

              <GitBranch className="h-3.5 w-3.5" /> Common Core

              <Separator orientation="vertical" className="h-3" />

              <Zap className="h-3.5 w-3.5" /> XP — (à brancher)

            </div>

          </div>

        </div>


        <div className="flex flex-wrap items-center gap-2">

          <Badge variant="secondary" className="gap-1"><Wallet2 className="h-3.5 w-3.5"/>Wallet: —</Badge>

          <Badge className="gap-1"><UserCheck className="h-3.5 w-3.5"/>Points de correction: —</Badge>

          <Badge className="gap-1" variant={blackHole.fill > 80 ? "destructive" : blackHole.fill > 60 ? "default" : "secondary"}>

            <TimerReset className="h-3.5 w-3.5"/> Black Hole {blackHole.fill}%

          </Badge>

          <Button variant="outline" size="icon" aria-label="Basculer le thème" onClick={() => setDark((d) => !d)}>

            <SunMoon className="h-4 w-4"/>

          </Button>

        </div>

      </header>


      {/* Black Hole timeline + Raccourcis */}

      <section className="grid grid-cols-1 lg:grid-cols-3 gap-4">

        <Card className="lg:col-span-2">

          <CardHeader className="flex flex-row items-center justify-between">

            <CardTitle className="text-base flex items-center gap-2"><TimerReset className="h-5 w-5"/>Black Hole — jalons</CardTitle>

          </CardHeader>

          <CardContent>

            <div className="space-y-4">

              <Progress value={blackHole.fill} className="h-2"/>

              <div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-sm">

                <InfoTile label="Entrée" value={fmt(blackHole.enterDate)} />

                <InfoTile label="Seuil J‑21" value={fmt(blackHole.j21)} />

                <InfoTile label="Seuil J‑7" value={fmt(blackHole.j7)} />

                <InfoTile label="Deadline" value={fmt(blackHole.deadline)} />

              </div>

            </div>

          </CardContent>

        </Card>


        <Card>

          <CardHeader>

            <CardTitle className="text-base">Raccourcis</CardTitle>

          </CardHeader>

          <CardContent className="grid grid-cols-2 gap-2 text-sm">

            <QuickLink label="Intra" />

            <QuickLink label="Projets" />

            <QuickLink label="Corrections" />

            <QuickLink label="Coalitions" />

          </CardContent>

        </Card>

      </section>


      {/* Corps : Projets / Agenda */}

      <section className="grid grid-cols-1 lg:grid-cols-3 gap-4">

        {/* Projets */}

        <Card className="lg:col-span-2">

          <CardHeader className="flex flex-row items-center justify-between">

            <CardTitle className="text-base flex items-center gap-2"><TerminalSquare className="h-5 w-5"/>Projets</CardTitle>

            <Tabs defaultValue="actifs" className="w-auto">

              <TabsList>

                <TabsTrigger value="actifs">Actifs</TabsTrigger>

                <TabsTrigger value="tous">Tous</TabsTrigger>

              </TabsList>

            </Tabs>

          </CardHeader>

          <CardContent>

            <div className="divide-y">

              {projects.map((p) => (

                <motion.div key={p.id} initial={{ opacity: 0, y: 6 }} animate={{ opacity: 1, y: 0 }} className="py-3 flex items-center justify-between">

                  <div className="space-y-1">

                    <div className="font-medium flex items-center gap-2">

                      {p.score === 100 ? <CheckCircle2 className="h-4 w-4"/> : <ArrowRight className="h-4 w-4"/>}

                      {p.title}

                    </div>

                    <div className="text-xs text-muted-foreground">Mis à jour le {p.updated}</div>

                  </div>

                  <div className="flex items-center gap-3">

                    {p.score !== null ? <Badge variant="secondary">{p.score}%</Badge> : <Badge>En cours</Badge>}

                    <Badge variant={String(p.status).includes("valid") ? "secondary" : "default"}>{p.status}</Badge>

                  </div>

                </motion.div>

              ))}

            </div>

          </CardContent>

        </Card>


        {/* Agenda (depuis Odoo) */}

        <Card>

          <CardHeader>

            <CardTitle className="text-base flex items-center gap-2"><CalendarDays className="h-5 w-5"/>Agenda</CardTitle>

          </CardHeader>

          <CardContent className="space-y-3 text-sm">

            {agenda.length === 0 && <div className="text-xs text-muted-foreground">Aucun événement à venir.</div>}

            {agenda.map((a) => (

              <div key={a.id} className="p-2 rounded-lg border">

                <div className="font-medium">{a.title}</div>

                <div className="flex items-center gap-2 text-xs text-muted-foreground"><Clock className="h-4 w-4"/>{a.when}</div>

                <div className="flex items-center gap-2 text-xs text-muted-foreground"><MapPin className="h-4 w-4"/>{a.room}</div>

              </div>

            ))}

          </CardContent>

        </Card>

      </section>


      {/* Corrections */}

      <section>

        <Card>

          <CardHeader>

            <CardTitle className="text-base flex items-center gap-2"><Coins className="h-5 w-5"/>Corrections</CardTitle>

          </CardHeader>

          <CardContent className="grid gap-3 text-sm md:grid-cols-2">

            <div>

              <div className="text-xs text-muted-foreground mb-1">À faire</div>

              <div className="grid gap-2">

                {corrections.aFaire.map((x) => (

                  <div key={x.id} className="p-2 rounded-lg border">

                    <div className="font-medium">{x.title}</div>

                    <div className="text-xs text-muted-foreground">{x.when}</div>

                  </div>

                ))}

              </div>

            </div>

            <div>

              <div className="text-xs text-muted-foreground mb-1">À recevoir</div>

              <div className="grid gap-2">

                {corrections.aRecevoir.map((x) => (

                  <div key={x.id} className="p-2 rounded-lg border">

                    <div className="font-medium">{x.title}</div>

                    <div className="text-xs text-muted-foreground">{x.when}</div>

                  </div>

                ))}

              </div>

            </div>

          </CardContent>

        </Card>

      </section>


      {/* Footer lite */}

      <footer className="pt-2 text-xs text-muted-foreground flex items-center justify-between">

        <div>© 2025 Arovy — Maquette 42 • vJS</div>

        <div className="flex items-center gap-3">

          <a className="underline underline-offset-4" href="#">Mentions</a>

          <a className="underline underline-offset-4" href="#">Confidentialité</a>

        </div>

      </footer>

    </div>

  );

}


// ---------------------------

// Helpers UI + mapping

// ---------------------------

function QuickLink({ label }) {

  return (

    <Button variant="outline" className="justify-start">

      <ArrowRight className="h-4 w-4 mr-2"/>{label}

    </Button>

  );

}


function InfoTile({ label, value }) {

  return (

    <div className="p-2 rounded-lg border">

      <div className="text-xs text-muted-foreground">{label}</div>

      <div className="font-medium">{value}</div>

    </div>

  );

}


function fmt(dateISO) {

  try {

    if (!dateISO) return "—";

    const d = new Date(dateISO);

    return d.toLocaleDateString("fr-FR", { year: "numeric", month: "short", day: "2-digit" });

  } catch {

    return dateISO;

  }

}


function toUiDashboard(j) {

  const projects = (j.projects || [])

    .filter((p) => p.project && p.project.name)

    .slice(0, 6)

    .map((p) => ({

      id: p.id,

      title: p.project.name,

      updated: new Date(p.updated_at || p.created_at).toLocaleDateString("fr-FR", {

        day: "2-digit",

        month: "short",

        year: "numeric",

      }),

      score: p.final_mark != null ? p.final_mark : null,

      status: (p.status || "—").replaceAll("_", " "),

    }));


  const deadline = j.blackHole && j.blackHole.blackholed_at ? j.blackHole.blackholed_at : "";

  const enter = j.blackHole && j.blackHole.enterDate ? j.blackHole.enterDate : "";

  const dDeadline = deadline ? new Date(deadline) : null;

  const dEnter = enter ? new Date(enter) : null;

  const j21 = dDeadline ? new Date(dDeadline.getTime() - 21 * 24 * 3600 * 1000) : null;

  const j7 = dDeadline ? new Date(dDeadline.getTime() - 7 * 24 * 3600 * 1000) : null;


  let fill = 0;

  if (dDeadline && dEnter) {

    const total = dDeadline.getTime() - dEnter.getTime();

    const done = Date.now() - dEnter.getTime();

    fill = Math.min(100, Math.max(0, Math.round((100 * done) / total)));

  }


  const toUiCorr = (x) => ({

    id: x.id,

    title:

      (x.team && x.team.project_gitlab_path && x.team.project_gitlab_path.split("/").pop()) ||

      (x.team && (x.team.project_name || x.team.topic)) ||

      "Correction",

    when: x.begin_at

      ? new Date(x.begin_at).toLocaleString("fr-FR", { dateStyle: "short", timeStyle: "short" })

      : "à planifier",

  });


  return {

    me: j.me || null,

    projects,

    blackHole: {

      enterDate: enter,

      blackholed_at: deadline,

      fill,

      j21: j21 ? j21.toISOString() : "",

      j7: j7 ? j7.toISOString() : "",

      deadline,

    },

    corrections: {

      aFaire: (j.corrections && j.corrections.aFaire ? j.corrections.aFaire : []).slice(0, 3).map(toUiCorr),

      aRecevoir: (j.corrections && j.corrections.aRecevoir ? j.corrections.aRecevoir : []).slice(0, 3).map(toUiCorr),

    },

  };

}


function toUiAgenda(events) {

  return (events || []).map((e) => ({

    id: e.id,

    title: e.name,

    when:

      new Date(e.start).toLocaleString("fr-FR", { dateStyle: "short", timeStyle: "short" }) +

      (e.stop ? ` — ${new Date(e.stop).toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit" })}` : ""),

    room: e.location || "—",

  }));

}