Enhance project setup with Prisma, new scripts, and dependencies; update README for clarity and add API routes; improve layout and styling for better user experience

This commit is contained in:
Julien Froidefond
2026-02-20 09:12:37 +01:00
parent 4ecd13a93a
commit f0c5d768db
33 changed files with 4277 additions and 107 deletions

66
src/app/admin/page.tsx Normal file
View File

@@ -0,0 +1,66 @@
"use client";
import { useState, useEffect } from "react";
interface Template {
id: string;
name: string;
dimensions: { id: string; title: string; orderIndex: number }[];
}
export default function AdminPage() {
const [templates, setTemplates] = useState<Template[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch("/api/templates")
.then((r) => r.json())
.then(setTemplates)
.finally(() => setLoading(false));
}, []);
if (loading) {
return <div className="py-12 text-center font-mono text-xs text-zinc-600 dark:text-zinc-500">loading...</div>;
}
return (
<div>
<h1 className="mb-6 font-mono text-lg font-medium text-zinc-800 dark:text-zinc-200">Admin</h1>
<p className="mb-6 font-mono text-xs text-zinc-600 dark:text-zinc-500">
Modèles. CRUD via /api/templates
</p>
<section>
<h2 className="mb-4 font-mono text-xs text-zinc-600 dark:text-zinc-500">Modèles</h2>
<div className="space-y-3">
{templates.map((t) => (
<div
key={t.id}
className="rounded-lg border border-zinc-200 dark:border-zinc-600 bg-white dark:bg-zinc-800 p-4 shadow-sm dark:shadow-none"
>
<h3 className="font-medium text-zinc-800 dark:text-zinc-200">{t.name}</h3>
<p className="mt-1 font-mono text-xs text-zinc-600 dark:text-zinc-500">
{t.dimensions.length} dim.
</p>
<ul className="mt-2 space-y-0.5 font-mono text-xs text-zinc-600 dark:text-zinc-400">
{t.dimensions.slice(0, 5).map((d) => (
<li key={d.id}> {d.title}</li>
))}
{t.dimensions.length > 5 && (
<li className="text-zinc-600 dark:text-zinc-500">+{t.dimensions.length - 5}</li>
)}
</ul>
</div>
))}
</div>
</section>
<section className="mt-8">
<h2 className="mb-2 font-mono text-xs text-zinc-600 dark:text-zinc-500">Users</h2>
<p className="font-mono text-xs text-zinc-600 dark:text-zinc-500">
admin@cars-front.local
</p>
</section>
</div>
);
}

38
src/app/api/auth/route.ts Normal file
View File

@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from "next/server";
/**
* Mock auth - MVP: single admin login
* In production: use NextAuth, Clerk, or Supabase Auth
*/
const MOCK_ADMIN = {
email: "admin@cars-front.local",
name: "Admin User",
role: "admin",
};
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const { email, password } = body;
// Accept any email for MVP; in prod validate against DB
if (!email) {
return NextResponse.json({ error: "Email required" }, { status: 400 });
}
// Mock: accept "admin" or any password for dev
const user = {
...MOCK_ADMIN,
email: email || MOCK_ADMIN.email,
};
return NextResponse.json({ user, token: "mock-jwt-" + Date.now() });
} catch {
return NextResponse.json({ error: "Auth failed" }, { status: 500 });
}
}
export async function GET() {
// Check session - mock: always return admin
return NextResponse.json({ user: MOCK_ADMIN });
}

View File

@@ -0,0 +1,176 @@
import { NextRequest, NextResponse } from "next/server";
import { Prisma } from "@prisma/client";
import { prisma } from "@/lib/db";
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const evaluation = await prisma.evaluation.findUnique({
where: { id },
include: {
template: {
include: {
dimensions: { orderBy: { orderIndex: "asc" } },
},
},
dimensionScores: { include: { dimension: true } },
},
});
if (!evaluation) {
return NextResponse.json({ error: "Evaluation not found" }, { status: 404 });
}
// Prisma ORM omits suggestedQuestions in some contexts — fetch via raw
const templateId = evaluation.templateId;
const dimsRaw = evaluation.template
? ((await prisma.$queryRaw(
Prisma.sql`SELECT id, slug, title, rubric, "orderIndex", "suggestedQuestions" FROM "TemplateDimension" WHERE "templateId" = ${templateId} ORDER BY "orderIndex" ASC`
)) as { id: string; slug: string; title: string; rubric: string; orderIndex: number; suggestedQuestions: string | null }[])
: [];
const dimMap = new Map(dimsRaw.map((d) => [d.id, d]));
const out = {
...evaluation,
template: evaluation.template
? {
...evaluation.template,
dimensions: evaluation.template.dimensions.map((d) => {
const raw = dimMap.get(d.id);
return {
id: d.id,
slug: d.slug,
title: d.title,
rubric: d.rubric,
orderIndex: d.orderIndex,
suggestedQuestions: raw?.suggestedQuestions ?? d.suggestedQuestions,
};
}),
}
: null,
dimensionScores: evaluation.dimensionScores.map((ds) => ({
...ds,
dimension: ds.dimension
? {
id: ds.dimension.id,
slug: ds.dimension.slug,
title: ds.dimension.title,
rubric: ds.dimension.rubric,
suggestedQuestions: dimMap.get(ds.dimension.id)?.suggestedQuestions ?? ds.dimension.suggestedQuestions,
}
: null,
})),
};
return NextResponse.json(out);
} catch (e) {
console.error(e);
return NextResponse.json({ error: "Failed to fetch evaluation" }, { status: 500 });
}
}
export async function PUT(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const body = await req.json();
const { candidateName, candidateRole, evaluatorName, evaluationDate, status, findings, recommendations, dimensionScores } = body;
const existing = await prisma.evaluation.findUnique({ where: { id } });
if (!existing) {
return NextResponse.json({ error: "Evaluation not found" }, { status: 404 });
}
const updateData: Record<string, unknown> = {};
if (candidateName != null) updateData.candidateName = candidateName;
if (candidateRole != null) updateData.candidateRole = candidateRole;
if (evaluatorName != null) updateData.evaluatorName = evaluatorName;
if (evaluationDate != null) updateData.evaluationDate = new Date(evaluationDate);
if (status != null) updateData.status = status;
if (findings != null) updateData.findings = findings;
if (recommendations != null) updateData.recommendations = recommendations;
if (Object.keys(updateData).length > 0) {
await prisma.auditLog.create({
data: {
evaluationId: id,
action: "updated",
newValue: JSON.stringify(updateData),
},
});
}
const evaluation = await prisma.evaluation.update({
where: { id },
data: updateData,
include: {
template: { include: { dimensions: { orderBy: { orderIndex: "asc" } } } },
dimensionScores: { include: { dimension: true } },
},
});
if (dimensionScores && Array.isArray(dimensionScores)) {
for (const ds of dimensionScores) {
if (ds.dimensionId) {
await prisma.dimensionScore.upsert({
where: {
evaluationId_dimensionId: {
evaluationId: id,
dimensionId: ds.dimensionId,
},
},
update: {
score: ds.score,
justification: ds.justification,
examplesObserved: ds.examplesObserved,
confidence: ds.confidence,
candidateNotes: ds.candidateNotes,
},
create: {
evaluationId: id,
dimensionId: ds.dimensionId,
score: ds.score,
justification: ds.justification,
examplesObserved: ds.examplesObserved,
confidence: ds.confidence,
candidateNotes: ds.candidateNotes,
},
});
}
}
}
const updated = await prisma.evaluation.findUnique({
where: { id },
include: {
template: { include: { dimensions: { orderBy: { orderIndex: "asc" } } } },
dimensionScores: { include: { dimension: true } },
},
});
return NextResponse.json(updated);
} catch (e) {
console.error(e);
return NextResponse.json({ error: "Failed to update evaluation" }, { status: 500 });
}
}
export async function DELETE(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
await prisma.evaluation.delete({ where: { id } });
return NextResponse.json({ ok: true });
} catch (e) {
console.error(e);
return NextResponse.json({ error: "Failed to delete evaluation" }, { status: 500 });
}
}

View File

@@ -0,0 +1,87 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url);
const status = searchParams.get("status");
const templateId = searchParams.get("templateId");
const evaluations = await prisma.evaluation.findMany({
where: {
...(status && { status }),
...(templateId && { templateId }),
},
include: {
template: true,
dimensionScores: { include: { dimension: true } },
},
orderBy: { evaluationDate: "desc" },
});
return NextResponse.json(evaluations);
} catch (e) {
console.error(e);
return NextResponse.json({ error: "Failed to fetch evaluations" }, { status: 500 });
}
}
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const { candidateName, candidateRole, evaluatorName, evaluationDate, templateId } = body;
if (!candidateName || !candidateRole || !evaluatorName || !evaluationDate || !templateId) {
return NextResponse.json(
{ error: "Missing required fields: candidateName, candidateRole, evaluatorName, evaluationDate, templateId" },
{ status: 400 }
);
}
const template = await prisma.template.findUnique({
where: { id: templateId },
include: { dimensions: { orderBy: { orderIndex: "asc" } } },
});
if (!template) {
return NextResponse.json({ error: "Template not found" }, { status: 404 });
}
const evaluation = await prisma.evaluation.create({
data: {
candidateName,
candidateRole,
evaluatorName,
evaluationDate: new Date(evaluationDate),
templateId,
status: "draft",
},
include: {
template: true,
dimensionScores: { include: { dimension: true } },
},
});
// Create empty dimension scores for each template dimension
for (const dim of template.dimensions) {
await prisma.dimensionScore.create({
data: {
evaluationId: evaluation.id,
dimensionId: dim.id,
},
});
}
const updated = await prisma.evaluation.findUnique({
where: { id: evaluation.id },
include: {
template: true,
dimensionScores: { include: { dimension: true } },
},
});
return NextResponse.json(updated);
} catch (e) {
console.error(e);
return NextResponse.json({ error: "Failed to create evaluation" }, { status: 500 });
}
}

View File

@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { evaluationToCsvRows } from "@/lib/export-utils";
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url);
const id = searchParams.get("id");
if (!id) {
return NextResponse.json({ error: "Evaluation id required" }, { status: 400 });
}
const evaluation = await prisma.evaluation.findUnique({
where: { id },
include: {
template: true,
dimensionScores: { include: { dimension: true } },
},
});
if (!evaluation) {
return NextResponse.json({ error: "Evaluation not found" }, { status: 404 });
}
const rows = evaluationToCsvRows(evaluation as Parameters<typeof evaluationToCsvRows>[0]);
const csv = rows.map((r) => r.map((c) => `"${String(c).replace(/"/g, '""')}"`).join(",")).join("\n");
return new NextResponse(csv, {
headers: {
"Content-Type": "text/csv",
"Content-Disposition": `attachment; filename="evaluation-${id}.csv"`,
},
});
} catch (e) {
console.error(e);
return NextResponse.json({ error: "Export failed" }, { status: 500 });
}
}

View File

@@ -0,0 +1,68 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { jsPDF } from "jspdf";
import autoTable from "jspdf-autotable";
import { format } from "date-fns";
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url);
const id = searchParams.get("id");
if (!id) {
return NextResponse.json({ error: "Evaluation id required" }, { status: 400 });
}
const evaluation = await prisma.evaluation.findUnique({
where: { id },
include: {
template: true,
dimensionScores: { include: { dimension: true } },
},
});
if (!evaluation) {
return NextResponse.json({ error: "Evaluation not found" }, { status: 404 });
}
const doc = new jsPDF();
doc.setFontSize(18);
doc.text("Évaluation Maturité IA Gen", 14, 20);
doc.setFontSize(10);
doc.text(`Candidat : ${evaluation.candidateName} | Rôle : ${evaluation.candidateRole}`, 14, 28);
doc.text(`Évaluateur : ${evaluation.evaluatorName} | Date : ${format(evaluation.evaluationDate, "yyyy-MM-dd")}`, 14, 34);
doc.text(`Modèle : ${evaluation.template?.name ?? ""} | Statut : ${evaluation.status === "submitted" ? "Soumise" : "Brouillon"}`, 14, 40);
const tableData = evaluation.dimensionScores.map((ds) => [
ds.dimension.title,
String(ds.score ?? "-"),
ds.justification ?? "",
ds.confidence ?? "",
]);
autoTable(doc, {
startY: 48,
head: [["Dimension", "Score", "Justification", "Confiance"]],
body: tableData,
theme: "striped",
});
const finalY =
(doc as unknown as { lastAutoTable?: { finalY: number } }).lastAutoTable?.finalY ?? 48;
doc.setFontSize(10);
doc.text("Synthèse :", 14, finalY + 12);
doc.text(evaluation.findings ?? "N/A", 14, finalY + 18, { maxWidth: 180 });
doc.text("Recommandations :", 14, finalY + 28);
doc.text(evaluation.recommendations ?? "N/A", 14, finalY + 34, { maxWidth: 180 });
const buf = Buffer.from(doc.output("arraybuffer"));
return new NextResponse(buf, {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="evaluation-${id}.pdf"`,
},
});
} catch (e) {
console.error(e);
return NextResponse.json({ error: "Export failed" }, { status: 500 });
}
}

View File

@@ -0,0 +1,29 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
/** Returns templates in the canonical JSON format: { templates: [{ id, name, dimensions: [{ id, title, rubric }] }] } */
export async function GET() {
try {
const templates = await prisma.template.findMany({
include: {
dimensions: { orderBy: { orderIndex: "asc" } },
},
});
const data = {
templates: templates.map((t) => ({
id: t.id,
name: t.name,
dimensions: t.dimensions.map((d) => ({
id: d.slug,
title: d.title,
rubric: d.rubric,
suggestedQuestions: d.suggestedQuestions,
})),
})),
};
return NextResponse.json(data);
} catch (e) {
console.error(e);
return NextResponse.json({ error: "Failed to fetch templates" }, { status: 500 });
}
}

View File

@@ -0,0 +1,29 @@
import { NextResponse } from "next/server";
import { Prisma } from "@prisma/client";
import { prisma } from "@/lib/db";
export async function GET() {
try {
const templates = await prisma.template.findMany({
include: {
dimensions: { orderBy: { orderIndex: "asc" } },
},
});
// Prisma ORM omits suggestedQuestions — enrich via raw
const dimsRaw = (await prisma.$queryRaw(
Prisma.sql`SELECT id, "templateId", slug, title, rubric, "orderIndex", "suggestedQuestions" FROM "TemplateDimension" ORDER BY "templateId", "orderIndex"`
)) as { id: string; templateId: string; slug: string; title: string; rubric: string; orderIndex: number; suggestedQuestions: string | null }[];
const dimMap = new Map(dimsRaw.map((d) => [d.id, d]));
const out = templates.map((t) => ({
...t,
dimensions: t.dimensions.map((d) => ({
...d,
suggestedQuestions: dimMap.get(d.id)?.suggestedQuestions ?? d.suggestedQuestions,
})),
}));
return NextResponse.json(out);
} catch (e) {
console.error(e);
return NextResponse.json({ error: "Failed to fetch templates" }, { status: 500 });
}
}

View File

@@ -0,0 +1,361 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useParams, useRouter } from "next/navigation";
import { CandidateForm } from "@/components/CandidateForm";
import { DimensionCard } from "@/components/DimensionCard";
import { RadarChart } from "@/components/RadarChart";
import { ExportModal } from "@/components/ExportModal";
import { ConfirmModal } from "@/components/ConfirmModal";
import { generateFindings, generateRecommendations, computeAverageScore } from "@/lib/export-utils";
interface Dimension {
id: string;
slug: string;
title: string;
rubric: string;
suggestedQuestions?: string | null;
}
interface DimensionScore {
id: string;
dimensionId: string;
score: number | null;
justification: string | null;
examplesObserved: string | null;
confidence: string | null;
candidateNotes: string | null;
dimension: Dimension;
}
interface Evaluation {
id: string;
candidateName: string;
candidateRole: string;
evaluatorName: string;
evaluationDate: string;
templateId: string;
template: { id: string; name: string; dimensions: Dimension[] };
status: string;
findings: string | null;
recommendations: string | null;
dimensionScores: DimensionScore[];
}
export default function EvaluationDetailPage() {
const params = useParams();
const router = useRouter();
const id = params.id as string;
const [evaluation, setEvaluation] = useState<Evaluation | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [templates, setTemplates] = useState<{ id: string; name: string }[]>([]);
const [exportOpen, setExportOpen] = useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const fetchEval = useCallback(() => {
setLoading(true);
Promise.all([
fetch(`/api/evaluations/${id}`).then((r) => r.json()),
fetch("/api/templates").then((r) => r.json()),
])
.then(([evalData, templatesData]) => {
setTemplates(Array.isArray(templatesData) ? templatesData : []);
if (evalData?.error) {
setEvaluation(null);
return;
}
try {
if (evalData?.template?.dimensions?.length > 0 && Array.isArray(templatesData)) {
const tmpl = templatesData.find((t: { id: string }) => t.id === evalData.templateId);
if (tmpl?.dimensions?.length) {
const dimMap = new Map(tmpl.dimensions.map((d: { id: string }) => [d.id, d]));
evalData.template.dimensions = evalData.template.dimensions.map((d: { id: string }) => ({
...d,
suggestedQuestions: d.suggestedQuestions ?? dimMap.get(d.id)?.suggestedQuestions,
}));
}
}
} catch {
/* merge failed, use evalData as-is */
}
setEvaluation({ ...evalData, dimensionScores: evalData.dimensionScores ?? [] });
})
.catch(() => setEvaluation(null))
.finally(() => setLoading(false));
}, [id]);
useEffect(() => {
fetchEval();
}, [fetchEval]);
// Draft backup to localStorage (debounced, for offline resilience)
useEffect(() => {
if (!evaluation || !id) return;
const t = setTimeout(() => {
try {
localStorage.setItem(
`eval-draft-${id}`,
JSON.stringify({ ...evaluation, evaluationDate: evaluation.evaluationDate })
);
} catch {
/* ignore */
}
}, 2000);
return () => clearTimeout(t);
}, [evaluation, id]);
const handleFormChange = (field: string, value: string) => {
if (!evaluation) return;
setEvaluation((e) => (e ? { ...e, [field]: value } : null));
};
const handleScoreChange = (dimensionId: string, data: Partial<DimensionScore>) => {
if (!evaluation) return;
setEvaluation((e) => {
if (!e) return null;
const scores = e.dimensionScores.map((ds) =>
ds.dimensionId === dimensionId ? { ...ds, ...data } : ds
);
return { ...e, dimensionScores: scores };
});
};
const handleSave = async () => {
if (!evaluation) return;
setSaving(true);
try {
const res = await fetch(`/api/evaluations/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
candidateName: evaluation.candidateName,
candidateRole: evaluation.candidateRole,
evaluatorName: evaluation.evaluatorName,
evaluationDate: evaluation.evaluationDate,
status: evaluation.status,
findings: evaluation.findings,
recommendations: evaluation.recommendations,
dimensionScores: (evaluation.dimensionScores ?? []).map((ds) => ({
dimensionId: ds.dimensionId,
evaluationId: id,
score: ds.score,
justification: ds.justification,
examplesObserved: ds.examplesObserved,
confidence: ds.confidence,
candidateNotes: ds.candidateNotes,
})),
}),
});
if (res.ok) {
fetchEval();
} else {
const data = await res.json();
alert(data.error ?? "Save failed");
}
} finally {
setSaving(false);
}
};
const handleGenerateFindings = () => {
if (!evaluation) return;
const findings = generateFindings(evaluation.dimensionScores ?? []);
const recommendations = generateRecommendations(evaluation.dimensionScores ?? []);
setEvaluation((e) => (e ? { ...e, findings, recommendations } : null));
};
const allFives = evaluation?.dimensionScores?.every(
(ds) => ds.score === 5 && (!ds.justification || ds.justification.trim() === "")
);
const showAllFivesWarning = allFives && evaluation?.status === "submitted";
if (loading) {
return <div className="py-12 text-center font-mono text-xs text-zinc-600 dark:text-zinc-500">loading...</div>;
}
if (!evaluation) {
return (
<div className="py-12 text-center font-mono text-xs text-zinc-600 dark:text-zinc-500">
Évaluation introuvable.{" "}
<a href="/" className="text-cyan-600 dark:text-cyan-400 hover:underline">
dashboard
</a>
</div>
);
}
const dimensions = evaluation.template?.dimensions ?? [];
const dimensionScores = evaluation.dimensionScores ?? [];
const scoreMap = new Map(dimensionScores.map((ds) => [ds.dimensionId, ds]));
const radarData = dimensionScores
.filter((ds) => ds.score != null)
.map((ds) => {
const title = ds.dimension?.title ?? "";
return {
dimension: title.length > 12 ? title.slice(0, 12) + "…" : title,
score: ds.score ?? 0,
fullMark: 5,
};
});
const avgScore = computeAverageScore(dimensionScores);
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<h1 className="font-mono text-base font-medium text-zinc-800 dark:text-zinc-100">
{evaluation.candidateName} <span className="text-zinc-500">/</span> {evaluation.candidateRole}
</h1>
<div className="flex gap-2">
<button
onClick={handleSave}
disabled={saving}
className="rounded border border-zinc-300 dark:border-zinc-600 bg-zinc-100 dark:bg-zinc-700 px-3 py-1.5 font-mono text-xs text-zinc-700 dark:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-zinc-700 disabled:opacity-50"
>
{saving ? "..." : "save"}
</button>
<button
onClick={() => setExportOpen(true)}
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"
>
export
</button>
</div>
</div>
{showAllFivesWarning && (
<div className="rounded border border-amber-500/30 bg-amber-500/10 p-3 font-mono text-xs text-amber-600 dark:text-amber-400">
Tous les scores = 5 sans justification
</div>
)}
<section className="rounded-lg border border-zinc-200 dark:border-zinc-600 bg-white dark:bg-zinc-800 p-4 shadow-sm dark:shadow-none">
<h2 className="mb-3 font-mono text-xs text-zinc-600 dark:text-zinc-500">Session</h2>
<CandidateForm
candidateName={evaluation.candidateName}
candidateRole={evaluation.candidateRole}
evaluatorName={evaluation.evaluatorName}
evaluationDate={evaluation.evaluationDate.split("T")[0]}
templateId={evaluation.templateId}
templates={templates}
onChange={handleFormChange}
templateDisabled
/>
</section>
<section>
<h2 className="mb-3 font-mono text-xs text-zinc-600 dark:text-zinc-500">Dimensions</h2>
<nav className="mb-4 flex flex-wrap gap-1.5">
{dimensions.map((dim, i) => {
const ds = scoreMap.get(dim.id);
const hasScore = ds?.score != null;
return (
<a
key={dim.id}
href={`#dim-${dim.id}`}
className={`rounded px-2 py-0.5 font-mono text-xs transition-colors ${
hasScore
? "bg-cyan-500/20 text-cyan-600 dark:text-cyan-400 hover:bg-cyan-500/30"
: "text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800 hover:text-zinc-700 dark:hover:text-zinc-300"
}`}
>
{i + 1}. {dim.title}
</a>
);
})}
</nav>
<div className="space-y-2">
{dimensions.map((dim, i) => (
<div key={dim.id} id={`dim-${dim.id}`} className="scroll-mt-24">
<DimensionCard
dimension={dim}
index={i}
score={scoreMap.get(dim.id) ?? null}
onScoreChange={handleScoreChange}
/>
</div>
))}
</div>
</section>
<section className="rounded-lg border border-zinc-200 dark:border-zinc-600 bg-white dark:bg-zinc-800 p-4 shadow-sm dark:shadow-none">
<h2 className="mb-3 font-mono text-xs text-zinc-600 dark:text-zinc-500">Synthèse</h2>
<p className="mb-4 font-mono text-sm text-zinc-700 dark:text-zinc-300">
Moyenne <span className="text-cyan-600 dark:text-cyan-400">{avgScore.toFixed(1)}/5</span>
</p>
<RadarChart data={radarData} />
<div className="mt-4 grid gap-4 sm:grid-cols-2">
<div>
<label className="mb-1 block text-xs text-zinc-600 dark:text-zinc-500">Synthèse</label>
<textarea
value={evaluation.findings ?? ""}
onChange={(e) => setEvaluation((ev) => (ev ? { ...ev, findings: e.target.value } : null))}
rows={3}
className="w-full rounded border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-700/80 px-2.5 py-1.5 text-sm text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 dark:placeholder-zinc-500 focus:border-cyan-500"
/>
</div>
<div>
<label className="mb-1 block text-xs text-zinc-600 dark:text-zinc-500">Recommandations</label>
<textarea
value={evaluation.recommendations ?? ""}
onChange={(e) =>
setEvaluation((ev) => (ev ? { ...ev, recommendations: e.target.value } : null))
}
rows={3}
className="w-full rounded border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-700/80 px-2.5 py-1.5 text-sm text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 dark:placeholder-zinc-500 focus:border-cyan-500"
/>
</div>
</div>
<button
type="button"
onClick={handleGenerateFindings}
className="mt-2 font-mono text-xs text-cyan-600 dark:text-cyan-400 hover:text-cyan-500 dark:hover:text-cyan-300"
>
auto-générer
</button>
</section>
<div className="flex flex-wrap gap-3">
<button
onClick={() => {
setEvaluation((e) => (e ? { ...e, status: "submitted" } : null));
setTimeout(handleSave, 0);
}}
className="rounded border border-emerald-500/50 bg-emerald-500/20 px-3 py-1.5 font-mono text-xs text-emerald-600 dark:text-emerald-400 hover:bg-emerald-500/30"
>
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">
dashboard
</button>
<button
type="button"
onClick={() => setDeleteConfirmOpen(true)}
className="rounded border border-red-500/30 px-3 py-1.5 font-mono text-xs text-red-600 dark:text-red-400 hover:bg-red-500/10"
>
supprimer
</button>
</div>
<ExportModal
isOpen={exportOpen}
onClose={() => setExportOpen(false)}
evaluationId={id}
/>
<ConfirmModal
isOpen={deleteConfirmOpen}
title="Supprimer l'évaluation"
message={`Supprimer l'évaluation de ${evaluation.candidateName} ? Cette action est irréversible.`}
confirmLabel="Supprimer"
cancelLabel="Annuler"
variant="danger"
onConfirm={async () => {
const res = await fetch(`/api/evaluations/${id}`, { method: "DELETE" });
if (res.ok) router.push("/");
else alert("Erreur lors de la suppression");
}}
onCancel={() => setDeleteConfirmOpen(false)}
/>
</div>
);
}

View File

@@ -0,0 +1,91 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { CandidateForm } from "@/components/CandidateForm";
export default function NewEvaluationPage() {
const router = useRouter();
const [templates, setTemplates] = useState<{ id: string; name: string }[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [form, setForm] = useState({
candidateName: "",
candidateRole: "",
evaluatorName: "",
evaluationDate: new Date().toISOString().split("T")[0],
templateId: "",
});
useEffect(() => {
fetch("/api/templates")
.then((r) => r.json())
.then((data) => {
setTemplates(data);
if (data.length > 0 && !form.templateId) {
setForm((f) => ({ ...f, templateId: data[0].id }));
}
})
.finally(() => setLoading(false));
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!form.templateId) return;
setSaving(true);
try {
const res = await fetch("/api/evaluations", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...form,
evaluationDate: new Date(form.evaluationDate).toISOString(),
}),
});
const data = await res.json();
if (res.ok) {
router.push(`/evaluations/${data.id}`);
} else {
alert(data.error ?? "Erreur");
}
} finally {
setSaving(false);
}
};
if (loading) {
return <div className="py-12 text-center font-mono text-xs text-zinc-600 dark:text-zinc-500">loading templates...</div>;
}
return (
<div>
<h1 className="mb-6 font-mono text-lg font-medium text-zinc-800 dark:text-zinc-200">Nouvelle évaluation</h1>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="rounded-lg border border-zinc-200 dark:border-zinc-600 bg-white dark:bg-zinc-800 p-4 shadow-sm dark:shadow-none">
<h2 className="mb-3 font-mono text-xs text-zinc-600 dark:text-zinc-500">Session</h2>
<CandidateForm
{...form}
templates={templates}
onChange={(field, value) => setForm((f) => ({ ...f, [field]: value }))}
/>
</div>
<div className="flex gap-2">
<button
type="submit"
disabled={saving || !form.templateId}
className="rounded border border-cyan-500/50 bg-cyan-500/20 px-4 py-2 font-mono text-sm text-cyan-600 dark:text-cyan-400 hover:bg-cyan-500/30 disabled:opacity-50"
>
{saving ? "..." : "créer →"}
</button>
<button
type="button"
onClick={() => router.back()}
className="rounded border border-zinc-300 dark:border-zinc-700 px-4 py-2 font-mono text-sm text-zinc-600 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800"
>
annuler
</button>
</div>
</form>
</div>
);
}

View File

@@ -1,26 +1,27 @@
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
:root {
--background: #ffffff;
--foreground: #171717;
--accent: #0891b2;
--accent-hover: #0e7490;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
body {
font-family: var(--font-sans), system-ui, sans-serif;
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
/* Tech input styling */
input, select, textarea {
font-family: inherit;
}
input:focus, select:focus, textarea:focus {
outline: none;
ring: 2px;
}

View File

@@ -1,5 +1,7 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Header } from "@/components/Header";
import { ThemeProvider } from "@/components/ThemeProvider";
import "./globals.css";
const geistSans = Geist({
@@ -13,8 +15,8 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "Évaluateur Maturité IA Gen",
description: "Équipe Cars Front - Outil d'évaluation de la maturité IA Gen",
};
export default function RootLayout({
@@ -23,11 +25,12 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
<html lang="fr" suppressHydrationWarning>
<body className={`${geistSans.variable} ${geistMono.variable} min-h-screen bg-zinc-100 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-50 antialiased`}>
<ThemeProvider>
<Header />
<main className="mx-auto max-w-5xl px-4 py-6">{children}</main>
</ThemeProvider>
</body>
</html>
);

View File

@@ -1,65 +1,137 @@
import Image from "next/image";
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { format } from "date-fns";
import { ConfirmModal } from "@/components/ConfirmModal";
interface EvalRow {
id: string;
candidateName: string;
candidateRole: string;
evaluatorName: string;
evaluationDate: string;
template?: { name: string };
status: string;
}
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 Home() {
return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
<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>
<div className="overflow-hidden rounded-lg border border-zinc-200 dark:border-zinc-600 bg-white dark:bg-zinc-800 shadow-sm dark:shadow-none">
<table className="min-w-full">
<thead>
<tr className="border-b border-zinc-200 dark:border-zinc-600 bg-zinc-50 dark:bg-zinc-700/80">
<th className="px-4 py-2.5 text-left font-mono text-xs text-zinc-600 dark:text-zinc-400">Candidat</th>
<th className="px-4 py-2.5 text-left font-mono text-xs text-zinc-600 dark:text-zinc-400">Rôle</th>
<th className="px-4 py-2.5 text-left font-mono text-xs text-zinc-600 dark:text-zinc-400">Évaluateur</th>
<th className="px-4 py-2.5 text-left font-mono text-xs text-zinc-600 dark:text-zinc-400">Date</th>
<th className="px-4 py-2.5 text-left font-mono text-xs text-zinc-600 dark:text-zinc-400">Modèle</th>
<th className="px-4 py-2.5 text-left font-mono text-xs text-zinc-600 dark:text-zinc-400">Statut</th>
<th className="px-4 py-2.5 text-right font-mono text-xs text-zinc-600 dark:text-zinc-400"></th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={7} className="px-4 py-12 text-center font-mono text-xs text-zinc-600 dark:text-zinc-500">
loading...
</td>
</tr>
) : evaluations.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 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>
</td>
</tr>
) : (
evaluations.map((e) => (
<tr key={e.id} className="border-b border-zinc-200 dark:border-zinc-600/50 hover:bg-zinc-50 dark:hover:bg-zinc-700/50 transition-colors">
<td className="px-4 py-2.5 text-sm font-medium text-zinc-800 dark:text-zinc-100">{e.candidateName}</td>
<td className="px-4 py-2.5 text-sm text-zinc-600 dark:text-zinc-400">{e.candidateRole}</td>
<td className="px-4 py-2.5 text-sm text-zinc-600 dark:text-zinc-400">{e.evaluatorName}</td>
<td className="px-4 py-2.5 font-mono text-xs text-zinc-600 dark:text-zinc-400">
{format(new Date(e.evaluationDate), "yyyy-MM-dd")}
</td>
<td className="px-4 py-2.5 text-sm text-zinc-600 dark:text-zinc-400">{e.template?.name ?? ""}</td>
<td className="px-4 py-2.5">
<span
className={`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>
</td>
<td className="px-4 py-2.5 text-right">
<span className="inline-flex items-center gap-3">
<Link
href={`/evaluations/${e.id}`}
className="font-mono text-xs text-cyan-600 dark:text-cyan-400 hover:text-cyan-500 dark:hover:text-cyan-300"
>
</Link>
<button
type="button"
onClick={() => setDeleteTarget(e)}
className="font-mono text-xs text-red-500 hover:text-red-400"
title="Supprimer"
>
×
</button>
</span>
</td>
</tr>
))
)}
</tbody>
</table>
</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

@@ -0,0 +1,94 @@
"use client";
interface CandidateFormProps {
candidateName: string;
candidateRole: string;
evaluatorName: string;
evaluationDate: string;
templateId: string;
templates: { id: string; name: string }[];
onChange: (field: string, value: string) => void;
disabled?: boolean;
templateDisabled?: boolean;
}
const inputClass =
"w-full rounded border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-700/80 px-3 py-1.5 text-sm text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 dark:placeholder-zinc-500 focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500/50 transition-colors";
const labelClass = "mb-0.5 block text-xs font-medium text-zinc-600 dark:text-zinc-400";
export function CandidateForm({
candidateName,
candidateRole,
evaluatorName,
evaluationDate,
templateId,
templates,
onChange,
disabled,
templateDisabled,
}: CandidateFormProps) {
return (
<div className="flex flex-wrap items-end gap-x-6 gap-y-3">
<div className="min-w-[140px]">
<label className={labelClass}>Candidat</label>
<input
type="text"
value={candidateName}
onChange={(e) => onChange("candidateName", e.target.value)}
className={inputClass}
disabled={disabled}
placeholder="Alice Chen"
/>
</div>
<div className="min-w-[140px]">
<label className={labelClass}>Rôle</label>
<input
type="text"
value={candidateRole}
onChange={(e) => onChange("candidateRole", e.target.value)}
className={inputClass}
disabled={disabled}
placeholder="ML Engineer"
/>
</div>
<div className="min-w-[120px]">
<label className={labelClass}>Évaluateur</label>
<input
type="text"
value={evaluatorName}
onChange={(e) => onChange("evaluatorName", e.target.value)}
className={inputClass}
disabled={disabled}
placeholder="Jean D."
/>
</div>
<div className="min-w-[120px]">
<label className={labelClass}>Date</label>
<input
type="date"
value={evaluationDate}
onChange={(e) => onChange("evaluationDate", e.target.value)}
className={inputClass}
disabled={disabled}
/>
</div>
<div className="min-w-[160px]">
<label className={labelClass}>Modèle</label>
<select
value={templateId}
onChange={(e) => onChange("templateId", e.target.value)}
className={inputClass}
disabled={disabled || templateDisabled}
>
<option value=""></option>
{templates.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
</select>
</div>
</div>
);
}

View File

@@ -0,0 +1,64 @@
"use client";
interface ConfirmModalProps {
isOpen: boolean;
title: string;
message: string;
confirmLabel?: string;
cancelLabel?: string;
variant?: "danger" | "default";
onConfirm: () => void | Promise<void>;
onCancel: () => void;
}
export function ConfirmModal({
isOpen,
title,
message,
confirmLabel = "Confirmer",
cancelLabel = "Annuler",
variant = "default",
onConfirm,
onCancel,
}: ConfirmModalProps) {
if (!isOpen) return null;
const handleConfirm = async () => {
await onConfirm();
onCancel();
};
return (
<>
<div className="fixed inset-0 z-40 bg-black/60" onClick={onCancel} aria-hidden="true" />
<div
className="fixed left-1/2 top-1/2 z-50 w-full max-w-sm -translate-x-1/2 -translate-y-1/2 rounded-lg border border-zinc-200 dark:border-zinc-600 bg-white dark:bg-zinc-800 p-4 shadow-xl"
role="dialog"
aria-label={title}
>
<h3 className="mb-2 font-mono text-sm font-medium text-zinc-800 dark:text-zinc-100">{title}</h3>
<p className="mb-4 text-sm text-zinc-600 dark:text-zinc-400">{message}</p>
<div className="flex gap-3 justify-end">
<button
type="button"
onClick={onCancel}
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-400 hover:bg-zinc-100 dark:hover:bg-zinc-700"
>
{cancelLabel}
</button>
<button
type="button"
onClick={handleConfirm}
className={`rounded px-3 py-1.5 font-mono text-xs ${
variant === "danger"
? "border border-red-500/50 bg-red-500/20 text-red-600 dark:text-red-400 hover:bg-red-500/30"
: "border border-cyan-500/50 bg-cyan-500/20 text-cyan-600 dark:text-cyan-400 hover:bg-cyan-500/30"
}`}
>
{confirmLabel}
</button>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,190 @@
"use client";
import { useState } from "react";
interface Dimension {
id: string;
slug: string;
title: string;
rubric: string;
suggestedQuestions?: string | null;
}
interface DimensionScore {
score: number | null;
justification: string | null;
examplesObserved: string | null;
confidence: string | null;
candidateNotes: string | null;
}
interface DimensionCardProps {
dimension: Dimension;
score: DimensionScore | null;
index: number;
onScoreChange: (dimensionId: string, data: Partial<DimensionScore>) => void;
}
function parseRubric(rubric: string): string[] {
if (rubric === "1-5" || !rubric) return ["1", "2", "3", "4", "5"];
const labels: string[] = [];
for (let i = 1; i <= 5; i++) {
const m = rubric.match(new RegExp(`${i}:([^;]+)`));
labels.push(m ? m[1].trim() : String(i));
}
return labels;
}
function parseQuestions(s: string | null | undefined): string[] {
if (!s) return [];
try {
const arr = JSON.parse(s) as unknown;
return Array.isArray(arr) ? arr.filter((x): x is string => typeof x === "string") : [];
} catch {
return [];
}
}
const inputClass =
"w-full rounded border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-700/80 px-2.5 py-1.5 text-sm text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 dark:placeholder-zinc-500 focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500/30";
export function DimensionCard({ dimension, score, index, onScoreChange }: DimensionCardProps) {
const [notes, setNotes] = useState(score?.candidateNotes ?? "");
const hasQuestions = parseQuestions(dimension.suggestedQuestions).length > 0;
const [expanded, setExpanded] = useState(hasQuestions);
const currentScore = score?.score ?? null;
const rubricLabels = parseRubric(dimension.rubric);
const questions = parseQuestions(dimension.suggestedQuestions);
return (
<div className="rounded-lg border border-zinc-200 dark:border-zinc-600 bg-white dark:bg-zinc-800 shadow-sm dark:shadow-none overflow-hidden">
<button
type="button"
onClick={() => setExpanded((e) => !e)}
className="flex w-full items-center justify-between gap-4 px-4 py-3 text-left hover:bg-zinc-50 dark:hover:bg-zinc-700/50 transition-colors"
>
<div className="flex items-center gap-3 min-w-0">
<span className="font-mono text-xs text-zinc-500 tabular-nums w-5">{index + 1}.</span>
<span className="font-medium text-zinc-800 dark:text-zinc-100 truncate">{dimension.title}</span>
{currentScore != null && (
<span
className={`shrink-0 font-mono text-xs px-1.5 py-0.5 rounded ${
currentScore <= 2 ? "bg-amber-500/20 text-amber-600 dark:text-amber-400" :
currentScore >= 4 ? "bg-emerald-500/20 text-emerald-600 dark:text-emerald-400" :
"bg-zinc-300 dark:bg-zinc-700 text-zinc-700 dark:text-zinc-300"
}`}
>
{currentScore}/5
</span>
)}
</div>
<span className="text-zinc-500 text-sm">{expanded ? "" : "+"}</span>
</button>
{expanded && (
<div className="border-t border-zinc-200 dark:border-zinc-600 px-4 py-3 space-y-3">
{/* Questions suggérées - en premier */}
{questions.length > 0 && (
<div className="rounded bg-zinc-50 dark:bg-zinc-700/80 p-2.5">
<p className="mb-2 text-xs font-medium text-zinc-500">Questions suggérées</p>
<ol className="list-decimal list-inside space-y-1 text-sm text-zinc-700 dark:text-zinc-200">
{questions.map((q, i) => (
<li key={i}>{q}</li>
))}
</ol>
</div>
)}
{/* Rubric */}
<div className="rounded bg-zinc-50 dark:bg-zinc-700/80 p-2.5 font-mono text-xs">
{rubricLabels.map((r, i) => (
<div key={i} className="text-zinc-600 dark:text-zinc-300">
<span className="text-cyan-600 dark:text-cyan-500/80">{i + 1}</span> {r}
</div>
))}
</div>
{/* Quick score */}
<div className="flex items-center gap-1">
<span className="text-xs text-zinc-500 mr-2">Score:</span>
{[1, 2, 3, 4, 5].map((n) => (
<button
key={n}
type="button"
onClick={() => onScoreChange(dimension.id, { score: n })}
className={`w-8 h-8 rounded font-mono text-sm font-medium transition-colors ${
currentScore === n
? "bg-cyan-500 text-white"
: "bg-zinc-200 dark:bg-zinc-600 text-zinc-600 dark:text-zinc-300 hover:bg-zinc-300 dark:hover:bg-zinc-500 hover:text-zinc-800 dark:hover:text-zinc-100"
}`}
>
{n}
</button>
))}
{currentScore != null && (
<button
type="button"
onClick={() => onScoreChange(dimension.id, { score: null })}
className="ml-1 text-xs text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300"
>
reset
</button>
)}
</div>
{/* Notes candidat */}
<div>
<label className="text-xs text-zinc-500 mb-1 block">Notes candidat</label>
<textarea
value={notes}
onChange={(e) => {
setNotes(e.target.value);
onScoreChange(dimension.id, { candidateNotes: e.target.value });
}}
rows={2}
className={inputClass}
placeholder="Réponses du candidat..."
/>
</div>
{/* Justification, exemples, confiance */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
<div>
<label className="text-xs text-zinc-500">Justification</label>
<input
type="text"
value={score?.justification ?? ""}
onChange={(e) => onScoreChange(dimension.id, { justification: e.target.value || null })}
className={inputClass}
placeholder="Courte..."
/>
</div>
<div>
<label className="text-xs text-zinc-500">Exemples</label>
<input
type="text"
value={score?.examplesObserved ?? ""}
onChange={(e) => onScoreChange(dimension.id, { examplesObserved: e.target.value || null })}
className={inputClass}
placeholder="Concrets..."
/>
</div>
<div>
<label className="text-xs text-zinc-500">Confiance</label>
<select
value={score?.confidence ?? ""}
onChange={(e) => onScoreChange(dimension.id, { confidence: e.target.value || null })}
className={inputClass}
>
<option value=""></option>
<option value="low">Faible</option>
<option value="med">Moyenne</option>
<option value="high">Haute</option>
</select>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,51 @@
"use client";
interface ExportModalProps {
isOpen: boolean;
onClose: () => void;
evaluationId: string;
}
export function ExportModal({ isOpen, onClose, evaluationId }: ExportModalProps) {
if (!isOpen) return null;
const base = typeof window !== "undefined" ? window.location.origin : "";
const csvUrl = `${base}/api/export/csv?id=${evaluationId}`;
const pdfUrl = `${base}/api/export/pdf?id=${evaluationId}`;
return (
<>
<div className="fixed inset-0 z-40 bg-black/60" onClick={onClose} aria-hidden="true" />
<div
className="fixed left-1/2 top-1/2 z-50 w-full max-w-sm -translate-x-1/2 -translate-y-1/2 rounded-lg border border-zinc-200 dark:border-zinc-600 bg-white dark:bg-zinc-800 p-4 shadow-xl"
role="dialog"
aria-label="Export"
>
<h3 className="mb-4 font-mono text-sm font-medium text-zinc-800 dark:text-zinc-200">Export</h3>
<div className="flex flex-col gap-2">
<a
href={csvUrl}
download
className="rounded border border-zinc-300 dark:border-zinc-600 bg-zinc-100 dark:bg-zinc-700 px-4 py-2 text-center font-mono text-xs text-zinc-700 dark:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-zinc-700"
>
csv
</a>
<a
href={pdfUrl}
download
className="rounded border border-zinc-300 dark:border-zinc-600 bg-zinc-100 dark:bg-zinc-700 px-4 py-2 text-center font-mono text-xs text-zinc-700 dark:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-zinc-700"
>
pdf
</a>
</div>
<button
type="button"
onClick={onClose}
className="mt-4 w-full rounded border border-zinc-300 dark:border-zinc-700 py-2 font-mono text-xs text-zinc-600 dark:text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800"
>
fermer
</button>
</div>
</>
);
}

28
src/components/Header.tsx Normal file
View File

@@ -0,0 +1,28 @@
"use client";
import Link from "next/link";
import { ThemeToggle } from "./ThemeToggle";
export function Header() {
return (
<header className="sticky top-0 z-50 border-b border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-900 shadow-sm dark:shadow-none backdrop-blur-sm">
<div className="mx-auto flex h-12 max-w-6xl items-center justify-between px-4">
<Link href="/" className="font-mono text-sm font-medium text-zinc-900 dark:text-zinc-50 tracking-tight">
iag-eval
</Link>
<nav className="flex items-center gap-6 font-mono text-xs">
<Link href="/" 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">
/new
</Link>
<Link href="/admin" className="text-zinc-500 hover:text-cyan-600 dark:text-zinc-400 dark:hover:text-cyan-400 transition-colors">
/admin
</Link>
<ThemeToggle />
</nav>
</div>
</header>
);
}

View File

@@ -0,0 +1,76 @@
"use client";
import {
Radar,
RadarChart as RechartsRadar,
PolarGrid,
PolarAngleAxis,
PolarRadiusAxis,
ResponsiveContainer,
Legend,
Tooltip,
} from "recharts";
import { useTheme } from "./ThemeProvider";
interface DataPoint {
dimension: string;
score: number;
fullMark: number;
}
interface RadarChartProps {
data: DataPoint[];
}
const LIGHT = {
grid: "#d4d4d8",
tick: "#71717a",
axis: "#a1a1aa",
tooltipBg: "#fafafa",
tooltipBorder: "#e4e4e7",
tooltipText: "#18181b",
};
const DARK = {
grid: "#71717a",
tick: "#a1a1aa",
axis: "#d4d4d8",
tooltipBg: "#27272a",
tooltipBorder: "#52525b",
tooltipText: "#fafafa",
};
export function RadarChart({ data }: RadarChartProps) {
const { theme } = useTheme();
const c = theme === "dark" ? DARK : LIGHT;
if (data.length === 0) return null;
return (
<div className="h-72 w-full">
<ResponsiveContainer width="100%" height="100%">
<RechartsRadar data={data}>
<PolarGrid stroke={c.grid} />
<PolarAngleAxis dataKey="dimension" tick={{ fontSize: 9, fill: c.axis }} />
<PolarRadiusAxis angle={30} domain={[0, 5]} tick={{ fill: c.tick }} />
<Radar
name="Score"
dataKey="score"
stroke="#0891b2"
fill="#0891b2"
fillOpacity={0.2}
/>
<Tooltip
contentStyle={{
backgroundColor: c.tooltipBg,
border: `1px solid ${c.tooltipBorder}`,
borderRadius: "4px",
color: c.tooltipText,
fontSize: "12px",
}}
/>
<Legend wrapperStyle={{ fontSize: "11px" }} />
</RechartsRadar>
</ResponsiveContainer>
</div>
);
}

View File

@@ -0,0 +1,40 @@
"use client";
import { createContext, useContext, useEffect, useState } from "react";
type Theme = "light" | "dark";
const ThemeContext = createContext<{ theme: Theme; setTheme: (t: Theme) => void } | null>(null);
export function useTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error("useTheme must be used within ThemeProvider");
return ctx;
}
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setThemeState] = useState<Theme>("dark");
const [mounted, setMounted] = useState(false);
useEffect(() => {
const stored = localStorage.getItem("theme") as Theme | null;
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
const initial = stored ?? (prefersDark ? "dark" : "light");
setThemeState(initial);
setMounted(true);
}, []);
useEffect(() => {
if (!mounted) return;
document.documentElement.classList.toggle("dark", theme === "dark");
localStorage.setItem("theme", theme);
}, [theme, mounted]);
const setTheme = (t: Theme) => setThemeState(t);
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}

View File

@@ -0,0 +1,18 @@
"use client";
import { useTheme } from "./ThemeProvider";
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
return (
<button
type="button"
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
className="font-mono text-xs text-zinc-600 dark:text-zinc-500 hover:text-zinc-800 dark:hover:text-zinc-300 transition-colors"
title={theme === "dark" ? "Passer au thème clair" : "Passer au thème sombre"}
>
{theme === "dark" ? "☀" : "☽"}
</button>
);
}

7
src/lib/db.ts Normal file
View File

@@ -0,0 +1,7 @@
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;

View File

@@ -0,0 +1,91 @@
import { describe, it, expect } from "vitest";
import {
computeAverageScore,
generateFindings,
generateRecommendations,
evaluationToCsvRows,
} from "./export-utils";
describe("computeAverageScore", () => {
it("returns 0 for empty array", () => {
expect(computeAverageScore([])).toBe(0);
});
it("returns 0 when all scores are null", () => {
expect(computeAverageScore([{ score: null }, { score: null }])).toBe(0);
});
it("computes average of valid scores", () => {
expect(computeAverageScore([{ score: 2 }, { score: 4 }])).toBe(3);
});
it("ignores null and out-of-range scores", () => {
expect(computeAverageScore([{ score: 2 }, { score: null }, { score: 4 }, { score: 0 }])).toBe(3);
});
});
describe("generateFindings", () => {
const mkDim = (name: string) => ({ name });
const mkScore = (score: number, name: string) => ({ score, dimension: mkDim(name) });
it("returns message when no scores", () => {
expect(generateFindings([])).toContain("Aucun score");
});
it("includes average and strengths", () => {
const scores = [mkScore(4, "A"), mkScore(5, "B")];
const out = generateFindings(scores);
expect(out).toContain("4.5/5");
expect(out).toContain("A");
expect(out).toContain("B");
});
it("includes weak areas", () => {
const scores = [mkScore(1, "Weak"), mkScore(4, "Strong")];
const out = generateFindings(scores);
expect(out).toContain("Weak");
expect(out).toContain("Strong");
});
});
describe("generateRecommendations", () => {
const mkDim = (name: string) => ({ name });
it("returns generic when no weak scores", () => {
const scores = [{ score: 4, dimension: mkDim("A") }];
expect(generateRecommendations(scores)).toContain("pratiques");
});
it("mentions weak dimensions", () => {
const scores = [{ score: 1, dimension: mkDim("Gap") }];
expect(generateRecommendations(scores)).toContain("Gap");
});
});
describe("evaluationToCsvRows", () => {
it("produces header + one row per dimension", () => {
const evalData = {
candidateName: "Alice",
candidateRole: "Engineer",
evaluatorName: "Bob",
evaluationDate: new Date("2025-02-15"),
status: "draft",
dimensionScores: [
{
dimension: { name: "Data Quality" },
score: 3,
justification: "Good",
examplesObserved: "x",
confidence: "high",
},
],
} as Parameters<typeof evaluationToCsvRows>[0];
const rows = evaluationToCsvRows(evalData);
expect(rows[0]).toContain("candidateName");
expect(rows.length).toBe(2);
expect(rows[1]).toContain("Alice");
expect(rows[1]).toContain("Data Quality");
expect(rows[1]).toContain("3");
});
});

81
src/lib/export-utils.ts Normal file
View File

@@ -0,0 +1,81 @@
import type { Evaluation, DimensionScore, TemplateDimension, Template } from "@prisma/client";
export interface EvaluationWithScores extends Evaluation {
template?: Template | null;
dimensionScores: (DimensionScore & { dimension: TemplateDimension })[];
}
/** Compute average score across dimensions (1-5 scale) */
export function computeAverageScore(scores: { score: number | null }[]): number {
const valid = scores.filter((s) => s.score != null && s.score >= 1 && s.score <= 5);
if (valid.length === 0) return 0;
return valid.reduce((a, s) => a + (s.score ?? 0), 0) / valid.length;
}
/** Generate findings paragraph from scores (deterministic template) */
export function generateFindings(
scores: { score: number | null; dimension: { name?: string; title?: string } }[]
): string {
const withScore = scores.filter((s) => s.score != null);
if (withScore.length === 0) return "Aucun score enregistré pour l'instant.";
const avg = computeAverageScore(withScore);
const dimName = (d: { name?: string; title?: string }) => d.title ?? d.name ?? "";
const weak = withScore.filter((s) => (s.score ?? 0) <= 2).map((s) => dimName(s.dimension));
const strong = withScore.filter((s) => (s.score ?? 0) >= 4).map((s) => dimName(s.dimension));
let text = `Score de maturité global : ${avg.toFixed(1)}/5. `;
if (strong.length > 0) {
text += `Points forts : ${strong.join(", ")}. `;
}
if (weak.length > 0) {
text += `Axes d'amélioration : ${weak.join(", ")}.`;
} else if (strong.length === 0) {
text += "Les scores indiquent une maturité modérée sur les dimensions.";
}
return text;
}
/** Generate recommendations (deterministic template) */
export function generateRecommendations(
scores: { score: number | null; dimension: { name?: string; title?: string } }[]
): string {
const dimName = (d: { name?: string; title?: string }) => d.title ?? d.name ?? "";
const weak = scores.filter((s) => (s.score ?? 5) <= 2).map((s) => dimName(s.dimension));
if (weak.length === 0) return "Continuer à s'appuyer sur les pratiques actuelles. Envisager le mentorat pour les juniors.";
return `Prioriser la formation et le développement pour : ${weak.join(", ")}. Envisager des programmes de montée en compétence structurés et une expertise externe si les écarts sont importants.`;
}
/** Convert evaluation to CSV rows (one per dimension + metadata row) */
export function evaluationToCsvRows(evalData: EvaluationWithScores): string[][] {
const rows: string[][] = [];
rows.push([
"candidateName",
"candidateRole",
"evaluatorName",
"evaluationDate",
"template",
"status",
"dimension",
"score",
"justification",
"examplesObserved",
"confidence",
]);
for (const ds of evalData.dimensionScores) {
rows.push([
evalData.candidateName,
evalData.candidateRole,
evalData.evaluatorName,
evalData.evaluationDate.toISOString().split("T")[0],
evalData.template?.name ?? "",
evalData.status,
(ds.dimension as { name?: string; title?: string }).title ?? (ds.dimension as { name?: string }).name ?? "",
String(ds.score ?? ""),
ds.justification ?? "",
ds.examplesObserved ?? "",
ds.confidence ?? "",
]);
}
return rows;
}