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’à :
- Renseigner .env.local (variables en tête du fichier).
- Vérifier NEXT_PUBLIC_BASE_URL (ex. http://localhost:3000).
- 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 || "—",
}));
}