Update routing logic to redirect users to the dashboard after login and evaluation actions. Refactor middleware to handle public routes and adjust navigation links across the application for improved user experience.
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled

This commit is contained in:
Julien Froidefond
2026-02-20 13:26:53 +01:00
parent b1fb6762fe
commit 328200f8b4
6 changed files with 240 additions and 165 deletions

View File

@@ -24,7 +24,7 @@ export default function LoginPage() {
setError("Email ou mot de passe incorrect");
return;
}
window.location.href = "/";
window.location.href = "/dashboard";
} catch {
setError("Erreur de connexion");
} finally {

167
src/app/dashboard/page.tsx Normal file
View File

@@ -0,0 +1,167 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { format } from "date-fns";
import { ConfirmModal } from "@/components/ConfirmModal";
import { RadarChart } from "@/components/RadarChart";
interface Dimension {
id: string;
title: string;
}
interface EvalRow {
id: string;
candidateName: string;
candidateRole: string;
candidateTeam?: string | null;
evaluatorName: string;
evaluationDate: string;
template?: { name: string; dimensions?: Dimension[] };
status: string;
dimensionScores?: { dimensionId: string; score: number | null; dimension?: { title: string } }[];
}
function buildRadarData(e: EvalRow) {
const dimensions = e.template?.dimensions ?? [];
const scoreMap = new Map(
(e.dimensionScores ?? []).map((ds) => [ds.dimensionId, ds])
);
return dimensions
.filter((dim) => !(dim.title ?? "").startsWith("[Optionnel]"))
.map((dim) => {
const ds = scoreMap.get(dim.id);
const score = ds?.score;
if (score == null) return null;
const s = Number(score);
if (Number.isNaN(s) || s < 0 || s > 5) return null;
const title = dim.title ?? "";
return {
dimension: title.length > 12 ? title.slice(0, 12) + "…" : title,
score: s,
fullMark: 5,
};
})
.filter((d): d is { dimension: string; score: number; fullMark: number } => d != null);
}
export default function DashboardPage() {
const [evaluations, setEvaluations] = useState<EvalRow[]>([]);
const [loading, setLoading] = useState(true);
const [deleteTarget, setDeleteTarget] = useState<EvalRow | null>(null);
useEffect(() => {
fetch("/api/evaluations")
.then((r) => r.json())
.then(setEvaluations)
.catch(() => [])
.finally(() => setLoading(false));
}, []);
return (
<div>
<div className="mb-6 flex items-center justify-between">
<h1 className="font-mono text-lg font-medium text-zinc-800 dark:text-zinc-100">Évaluations</h1>
<Link
href="/evaluations/new"
className="rounded border border-cyan-500/50 bg-cyan-500/10 px-3 py-1.5 font-mono text-xs text-cyan-600 dark:text-cyan-400 hover:bg-cyan-500/20 transition-colors"
>
+ nouvelle
</Link>
</div>
{loading ? (
<div className="py-12 text-center font-mono text-xs text-zinc-600 dark:text-zinc-500">loading...</div>
) : evaluations.length === 0 ? (
<div className="py-12 text-center text-zinc-600 dark:text-zinc-500">
Aucune évaluation.{" "}
<Link href="/evaluations/new" className="text-cyan-600 dark:text-cyan-400 hover:underline">
Créer
</Link>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{evaluations.map((e) => {
const radarData = buildRadarData(e);
return (
<Link
key={e.id}
href={`/evaluations/${e.id}`}
className="group flex flex-col overflow-hidden rounded-lg border border-zinc-200 dark:border-zinc-600 bg-white dark:bg-zinc-800 shadow-sm dark:shadow-none hover:border-cyan-500/50 dark:hover:border-cyan-500/30 transition-colors"
>
<div className="flex flex-1 flex-col p-4">
<div className="mb-3 flex items-start justify-between gap-2">
<div className="min-w-0">
<h3 className="truncate font-medium text-zinc-800 dark:text-zinc-100">{e.candidateName}</h3>
<p className="truncate text-sm text-zinc-600 dark:text-zinc-400">
{e.candidateRole}
{e.candidateTeam && ` · ${e.candidateTeam}`}
</p>
</div>
<span
className={`shrink-0 font-mono text-xs px-1.5 py-0.5 rounded ${
e.status === "submitted" ? "bg-emerald-500/20 text-emerald-600 dark:text-emerald-400" : "bg-amber-500/20 text-amber-600 dark:text-amber-400"
}`}
>
{e.status === "submitted" ? "ok" : "draft"}
</span>
</div>
<div className="mb-3 flex flex-wrap gap-x-3 gap-y-0.5 font-mono text-xs text-zinc-500 dark:text-zinc-400">
<span>{e.evaluatorName}</span>
<span>{format(new Date(e.evaluationDate), "yyyy-MM-dd")}</span>
<span>{e.template?.name ?? ""}</span>
</div>
<div className="mt-auto min-h-[7rem]">
{radarData.length > 0 ? (
<RadarChart data={radarData} compact />
) : (
<div className="flex h-28 items-center justify-center rounded bg-zinc-50 dark:bg-zinc-700/30 font-mono text-xs text-zinc-400 dark:text-zinc-500">
pas de scores
</div>
)}
</div>
</div>
<div className="flex border-t border-zinc-200 dark:border-zinc-600 bg-zinc-50 dark:bg-zinc-700/30 px-4 py-2">
<span className="font-mono text-xs text-cyan-600 dark:text-cyan-400 group-hover:underline"> ouvrir</span>
<button
type="button"
onClick={(ev) => {
ev.preventDefault();
ev.stopPropagation();
setDeleteTarget(e);
}}
className="ml-auto font-mono text-xs text-red-500 hover:text-red-400"
title="Supprimer"
>
supprimer
</button>
</div>
</Link>
);
})}
</div>
)}
<ConfirmModal
isOpen={!!deleteTarget}
title="Supprimer l'évaluation"
message={
deleteTarget
? `Supprimer l'évaluation de ${deleteTarget.candidateName} ? Cette action est irréversible.`
: ""
}
confirmLabel="Supprimer"
cancelLabel="Annuler"
variant="danger"
onConfirm={async () => {
if (!deleteTarget) return;
const res = await fetch(`/api/evaluations/${deleteTarget.id}`, { method: "DELETE" });
if (res.ok) setEvaluations((prev) => prev.filter((x) => x.id !== deleteTarget.id));
else alert("Erreur lors de la suppression");
}}
onCancel={() => setDeleteTarget(null)}
/>
</div>
);
}

View File

@@ -212,7 +212,7 @@ export default function EvaluationDetailPage() {
return (
<div className="py-12 text-center font-mono text-xs text-zinc-600 dark:text-zinc-500">
Évaluation introuvable.{" "}
<Link href="/" className="text-cyan-600 dark:text-cyan-400 hover:underline">
<Link href="/dashboard" className="text-cyan-600 dark:text-cyan-400 hover:underline">
dashboard
</Link>
</div>
@@ -391,7 +391,7 @@ export default function EvaluationDetailPage() {
>
soumettre
</button>
<button onClick={() => router.push("/")} className="rounded border border-zinc-300 dark:border-zinc-600 px-3 py-1.5 font-mono text-xs text-zinc-600 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700">
<button onClick={() => router.push("/dashboard")} className="rounded border border-zinc-300 dark:border-zinc-600 px-3 py-1.5 font-mono text-xs text-zinc-600 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700">
dashboard
</button>
<button
@@ -428,7 +428,7 @@ export default function EvaluationDetailPage() {
variant="danger"
onConfirm={async () => {
const res = await fetch(`/api/evaluations/${id}`, { method: "DELETE" });
if (res.ok) router.push("/");
if (res.ok) router.push("/dashboard");
else alert("Erreur lors de la suppression");
}}
onCancel={() => setDeleteConfirmOpen(false)}

View File

@@ -1,167 +1,69 @@
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { format } from "date-fns";
import { ConfirmModal } from "@/components/ConfirmModal";
import { RadarChart } from "@/components/RadarChart";
interface Dimension {
id: string;
title: string;
}
interface EvalRow {
id: string;
candidateName: string;
candidateRole: string;
candidateTeam?: string | null;
evaluatorName: string;
evaluationDate: string;
template?: { name: string; dimensions?: Dimension[] };
status: string;
dimensionScores?: { dimensionId: string; score: number | null; dimension?: { title: string } }[];
}
function buildRadarData(e: EvalRow) {
const dimensions = e.template?.dimensions ?? [];
const scoreMap = new Map(
(e.dimensionScores ?? []).map((ds) => [ds.dimensionId, ds])
);
return dimensions
.filter((dim) => !(dim.title ?? "").startsWith("[Optionnel]"))
.map((dim) => {
const ds = scoreMap.get(dim.id);
const score = ds?.score;
if (score == null) return null;
const s = Number(score);
if (Number.isNaN(s) || s < 0 || s > 5) return null;
const title = dim.title ?? "";
return {
dimension: title.length > 12 ? title.slice(0, 12) + "…" : title,
score: s,
fullMark: 5,
};
})
.filter((d): d is { dimension: string; score: number; fullMark: number } => d != null);
}
export default function DashboardPage() {
const [evaluations, setEvaluations] = useState<EvalRow[]>([]);
const [loading, setLoading] = useState(true);
const [deleteTarget, setDeleteTarget] = useState<EvalRow | null>(null);
useEffect(() => {
fetch("/api/evaluations")
.then((r) => r.json())
.then(setEvaluations)
.catch(() => [])
.finally(() => setLoading(false));
}, []);
export default function LandingPage() {
return (
<div>
<div className="mb-6 flex items-center justify-between">
<h1 className="font-mono text-lg font-medium text-zinc-800 dark:text-zinc-100">Évaluations</h1>
<Link
href="/evaluations/new"
className="rounded border border-cyan-500/50 bg-cyan-500/10 px-3 py-1.5 font-mono text-xs text-cyan-600 dark:text-cyan-400 hover:bg-cyan-500/20 transition-colors"
>
+ nouvelle
</Link>
</div>
{loading ? (
<div className="py-12 text-center font-mono text-xs text-zinc-600 dark:text-zinc-500">loading...</div>
) : evaluations.length === 0 ? (
<div className="py-12 text-center text-zinc-600 dark:text-zinc-500">
Aucune évaluation.{" "}
<Link href="/evaluations/new" className="text-cyan-600 dark:text-cyan-400 hover:underline">
Créer
<div className="mx-auto max-w-3xl">
<div className="py-16 md:py-24">
<h1 className="font-mono text-3xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50 md:text-4xl">
Évaluez la maturité IA/GenAI de vos candidats
</h1>
<p className="mt-6 font-mono text-base leading-relaxed text-zinc-600 dark:text-zinc-400">
Grilles structurées, guide d&apos;entretien, rubriques 15, questions de relance.
Un outil pensé pour standardiser vos évaluations et générer synthèses et recommandations.
</p>
<div className="mt-10 flex flex-wrap gap-4">
<Link
href="/auth/login"
className="rounded-lg border border-cyan-500 bg-cyan-500 px-5 py-2.5 font-mono text-sm font-medium text-white hover:bg-cyan-600 dark:border-cyan-400 dark:bg-cyan-500/90 dark:text-zinc-900 dark:hover:bg-cyan-400 transition-colors"
>
Accéder à l&apos;app
</Link>
<Link
href="/auth/signup"
className="rounded-lg border border-zinc-300 dark:border-zinc-600 px-5 py-2.5 font-mono text-sm text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors"
>
Créer un compte
</Link>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{evaluations.map((e) => {
const radarData = buildRadarData(e);
return (
<Link
key={e.id}
href={`/evaluations/${e.id}`}
className="group flex flex-col overflow-hidden rounded-lg border border-zinc-200 dark:border-zinc-600 bg-white dark:bg-zinc-800 shadow-sm dark:shadow-none hover:border-cyan-500/50 dark:hover:border-cyan-500/30 transition-colors"
>
<div className="flex flex-1 flex-col p-4">
<div className="mb-3 flex items-start justify-between gap-2">
<div className="min-w-0">
<h3 className="truncate font-medium text-zinc-800 dark:text-zinc-100">{e.candidateName}</h3>
<p className="truncate text-sm text-zinc-600 dark:text-zinc-400">
{e.candidateRole}
{e.candidateTeam && ` · ${e.candidateTeam}`}
</p>
</div>
<span
className={`shrink-0 font-mono text-xs px-1.5 py-0.5 rounded ${
e.status === "submitted" ? "bg-emerald-500/20 text-emerald-600 dark:text-emerald-400" : "bg-amber-500/20 text-amber-600 dark:text-amber-400"
}`}
>
{e.status === "submitted" ? "ok" : "draft"}
</span>
</div>
<div className="mb-3 flex flex-wrap gap-x-3 gap-y-0.5 font-mono text-xs text-zinc-500 dark:text-zinc-400">
<span>{e.evaluatorName}</span>
<span>{format(new Date(e.evaluationDate), "yyyy-MM-dd")}</span>
<span>{e.template?.name ?? ""}</span>
</div>
<div className="mt-auto min-h-[7rem]">
{radarData.length > 0 ? (
<RadarChart data={radarData} compact />
) : (
<div className="flex h-28 items-center justify-center rounded bg-zinc-50 dark:bg-zinc-700/30 font-mono text-xs text-zinc-400 dark:text-zinc-500">
pas de scores
</div>
)}
</div>
</div>
<div className="flex border-t border-zinc-200 dark:border-zinc-600 bg-zinc-50 dark:bg-zinc-700/30 px-4 py-2">
<span className="font-mono text-xs text-cyan-600 dark:text-cyan-400 group-hover:underline"> ouvrir</span>
<button
type="button"
onClick={(ev) => {
ev.preventDefault();
ev.stopPropagation();
setDeleteTarget(e);
}}
className="ml-auto font-mono text-xs text-red-500 hover:text-red-400"
title="Supprimer"
>
supprimer
</button>
</div>
</Link>
);
})}
</div>
)}
</div>
<ConfirmModal
isOpen={!!deleteTarget}
title="Supprimer l'évaluation"
message={
deleteTarget
? `Supprimer l'évaluation de ${deleteTarget.candidateName} ? Cette action est irréversible.`
: ""
}
confirmLabel="Supprimer"
cancelLabel="Annuler"
variant="danger"
onConfirm={async () => {
if (!deleteTarget) return;
const res = await fetch(`/api/evaluations/${deleteTarget.id}`, { method: "DELETE" });
if (res.ok) setEvaluations((prev) => prev.filter((x) => x.id !== deleteTarget.id));
else alert("Erreur lors de la suppression");
}}
onCancel={() => setDeleteTarget(null)}
/>
<section className="border-t border-zinc-200 dark:border-zinc-700 py-12">
<h2 className="font-mono text-lg font-medium text-zinc-800 dark:text-zinc-200">
Fonctionnalités
</h2>
<ul className="mt-6 grid gap-6 sm:grid-cols-2">
<li className="rounded-lg border border-zinc-200 dark:border-zinc-600 bg-white dark:bg-zinc-800/50 p-4">
<span className="font-mono text-sm font-medium text-cyan-600 dark:text-cyan-400">Templates</span>
<p className="mt-1 font-mono text-xs text-zinc-600 dark:text-zinc-400">
Grilles multi-dimensions (Full 15, Short 8) avec rubriques et signaux par niveau.
</p>
</li>
<li className="rounded-lg border border-zinc-200 dark:border-zinc-600 bg-white dark:bg-zinc-800/50 p-4">
<span className="font-mono text-sm font-medium text-cyan-600 dark:text-cyan-400">Guide d&apos;entretien</span>
<p className="mt-1 font-mono text-xs text-zinc-600 dark:text-zinc-400">
Questions suggérées, relances IA, notation 15 avec justification et exemples.
</p>
</li>
<li className="rounded-lg border border-zinc-200 dark:border-zinc-600 bg-white dark:bg-zinc-800/50 p-4">
<span className="font-mono text-sm font-medium text-cyan-600 dark:text-cyan-400">Radar & synthèse</span>
<p className="mt-1 font-mono text-xs text-zinc-600 dark:text-zinc-400">
Visualisation radar, findings et recommandations générés automatiquement.
</p>
</li>
<li className="rounded-lg border border-zinc-200 dark:border-zinc-600 bg-white dark:bg-zinc-800/50 p-4">
<span className="font-mono text-sm font-medium text-cyan-600 dark:text-cyan-400">Export</span>
<p className="mt-1 font-mono text-xs text-zinc-600 dark:text-zinc-400">
Export PDF et CSV pour partage et archivage.
</p>
</li>
</ul>
</section>
<div className="border-t border-zinc-200 dark:border-zinc-700 py-8 text-center">
<p className="font-mono text-xs text-zinc-500 dark:text-zinc-400">
IA Gen Maturity Evaluator · Équipe Cars Front
</p>
</div>
</div>
);
}

View File

@@ -14,9 +14,9 @@ export function Header() {
iag-eval
</Link>
<nav className="flex items-center gap-6 font-mono text-xs">
{status === "authenticated" && (
{status === "authenticated" ? (
<>
<Link href="/" className="text-zinc-500 hover:text-cyan-600 dark:text-zinc-400 dark:hover:text-cyan-400 transition-colors">
<Link href="/dashboard" className="text-zinc-500 hover:text-cyan-600 dark:text-zinc-400 dark:hover:text-cyan-400 transition-colors">
/dashboard
</Link>
<Link href="/evaluations/new" className="text-zinc-500 hover:text-cyan-600 dark:text-zinc-400 dark:hover:text-cyan-400 transition-colors">
@@ -39,6 +39,10 @@ export function Header() {
déconnexion
</button>
</>
) : (
<Link href="/auth/login" className="text-zinc-500 hover:text-cyan-600 dark:text-zinc-400 dark:hover:text-cyan-400 transition-colors">
Se connecter
</Link>
)}
<ThemeToggle />
</nav>

View File

@@ -3,6 +3,7 @@ import { NextResponse } from "next/server";
export default auth((req) => {
const isLoggedIn = !!req.auth;
const isPublicRoute = req.nextUrl.pathname === "/";
const isAuthRoute =
req.nextUrl.pathname.startsWith("/auth/login") ||
req.nextUrl.pathname.startsWith("/auth/signup");
@@ -10,8 +11,9 @@ export default auth((req) => {
const isAdminRoute = req.nextUrl.pathname.startsWith("/admin");
if (isApiAuth) return NextResponse.next();
if (isPublicRoute) return NextResponse.next();
if (isAuthRoute && isLoggedIn) {
return NextResponse.redirect(new URL("/", req.nextUrl));
return NextResponse.redirect(new URL("/dashboard", req.nextUrl));
}
if (!isLoggedIn && !isAuthRoute) {
return NextResponse.redirect(new URL("/auth/login", req.nextUrl));