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

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>
);
}