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:
66
src/app/admin/page.tsx
Normal file
66
src/app/admin/page.tsx
Normal 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
38
src/app/api/auth/route.ts
Normal 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 });
|
||||
}
|
||||
176
src/app/api/evaluations/[id]/route.ts
Normal file
176
src/app/api/evaluations/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
87
src/app/api/evaluations/route.ts
Normal file
87
src/app/api/evaluations/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
38
src/app/api/export/csv/route.ts
Normal file
38
src/app/api/export/csv/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
68
src/app/api/export/pdf/route.ts
Normal file
68
src/app/api/export/pdf/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
29
src/app/api/templates/data/route.ts
Normal file
29
src/app/api/templates/data/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
29
src/app/api/templates/route.ts
Normal file
29
src/app/api/templates/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
361
src/app/evaluations/[id]/page.tsx
Normal file
361
src/app/evaluations/[id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
91
src/app/evaluations/new/page.tsx
Normal file
91
src/app/evaluations/new/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
192
src/app/page.tsx
192
src/app/page.tsx
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
94
src/components/CandidateForm.tsx
Normal file
94
src/components/CandidateForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
64
src/components/ConfirmModal.tsx
Normal file
64
src/components/ConfirmModal.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
190
src/components/DimensionCard.tsx
Normal file
190
src/components/DimensionCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
51
src/components/ExportModal.tsx
Normal file
51
src/components/ExportModal.tsx
Normal 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
28
src/components/Header.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
76
src/components/RadarChart.tsx
Normal file
76
src/components/RadarChart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
src/components/ThemeProvider.tsx
Normal file
40
src/components/ThemeProvider.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
src/components/ThemeToggle.tsx
Normal file
18
src/components/ThemeToggle.tsx
Normal 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
7
src/lib/db.ts
Normal 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;
|
||||
91
src/lib/export-utils.test.ts
Normal file
91
src/lib/export-utils.test.ts
Normal 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
81
src/lib/export-utils.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user