Compare commits

...

2 Commits

5 changed files with 140 additions and 85 deletions

View File

@@ -13,7 +13,7 @@ export async function GET(req: NextRequest) {
...(templateId && { templateId }), ...(templateId && { templateId }),
}, },
include: { include: {
template: true, template: { include: { dimensions: { orderBy: { orderIndex: "asc" } } } },
dimensionScores: { include: { dimension: true } }, dimensionScores: { include: { dimension: true } },
}, },
orderBy: { evaluationDate: "desc" }, orderBy: { evaluationDate: "desc" },
@@ -57,7 +57,7 @@ export async function POST(req: NextRequest) {
status: "draft", status: "draft",
}, },
include: { include: {
template: true, template: { include: { dimensions: { orderBy: { orderIndex: "asc" } } } },
dimensionScores: { include: { dimension: true } }, dimensionScores: { include: { dimension: true } },
}, },
}); });
@@ -75,7 +75,7 @@ export async function POST(req: NextRequest) {
const updated = await prisma.evaluation.findUnique({ const updated = await prisma.evaluation.findUnique({
where: { id: evaluation.id }, where: { id: evaluation.id },
include: { include: {
template: true, template: { include: { dimensions: { orderBy: { orderIndex: "asc" } } } },
dimensionScores: { include: { dimension: true } }, dimensionScores: { include: { dimension: true } },
}, },
}); });

View File

@@ -54,6 +54,7 @@ export default function EvaluationDetailPage() {
const [templates, setTemplates] = useState<{ id: string; name: string }[]>([]); const [templates, setTemplates] = useState<{ id: string; name: string }[]>([]);
const [exportOpen, setExportOpen] = useState(false); const [exportOpen, setExportOpen] = useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [collapseAllTrigger, setCollapseAllTrigger] = useState(0);
const fetchEval = useCallback(() => { const fetchEval = useCallback(() => {
setLoading(true); setLoading(true);
@@ -214,16 +215,22 @@ export default function EvaluationDetailPage() {
const dimensions = evaluation.template?.dimensions ?? []; const dimensions = evaluation.template?.dimensions ?? [];
const dimensionScores = evaluation.dimensionScores ?? []; const dimensionScores = evaluation.dimensionScores ?? [];
const scoreMap = new Map(dimensionScores.map((ds) => [ds.dimensionId, ds])); const scoreMap = new Map(dimensionScores.map((ds) => [ds.dimensionId, ds]));
const radarData = dimensionScores const radarData = dimensions
.filter((ds) => ds.score != null) .filter((dim) => !(dim.title ?? "").startsWith("[Optionnel]"))
.map((ds) => { .map((dim) => {
const title = ds.dimension?.title ?? ""; const ds = scoreMap.get(dim.id);
const score = ds?.score;
if (score == null) return null;
const title = dim.title ?? "";
const s = Number(score);
if (Number.isNaN(s) || s < 0 || s > 5) return null;
return { return {
dimension: title.length > 12 ? title.slice(0, 12) + "…" : title, dimension: title.length > 12 ? title.slice(0, 12) + "…" : title,
score: ds.score ?? 0, score: s,
fullMark: 5, fullMark: 5,
}; };
}); })
.filter((d): d is { dimension: string; score: number; fullMark: number } => d != null);
const avgScore = computeAverageScore(dimensionScores); const avgScore = computeAverageScore(dimensionScores);
return ( return (
@@ -275,7 +282,16 @@ export default function EvaluationDetailPage() {
</section> </section>
<section> <section>
<h2 className="mb-3 font-mono text-xs text-zinc-600 dark:text-zinc-500">Dimensions</h2> <div className="mb-3 flex items-center justify-between gap-2">
<h2 className="font-mono text-xs text-zinc-600 dark:text-zinc-500">Dimensions</h2>
<button
type="button"
onClick={() => setCollapseAllTrigger((c) => c + 1)}
className="font-mono text-xs text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300"
>
tout fermer
</button>
</div>
<nav className="mb-4 flex flex-wrap gap-1.5"> <nav className="mb-4 flex flex-wrap gap-1.5">
{dimensions.map((dim, i) => { {dimensions.map((dim, i) => {
const ds = scoreMap.get(dim.id); const ds = scoreMap.get(dim.id);
@@ -304,6 +320,7 @@ export default function EvaluationDetailPage() {
evaluationId={id} evaluationId={id}
score={scoreMap.get(dim.id) ?? null} score={scoreMap.get(dim.id) ?? null}
onScoreChange={handleScoreChange} onScoreChange={handleScoreChange}
collapseAllTrigger={collapseAllTrigger}
/> />
</div> </div>
))} ))}

View File

@@ -4,6 +4,12 @@ import { useState, useEffect } from "react";
import Link from "next/link"; import Link from "next/link";
import { format } from "date-fns"; import { format } from "date-fns";
import { ConfirmModal } from "@/components/ConfirmModal"; import { ConfirmModal } from "@/components/ConfirmModal";
import { RadarChart } from "@/components/RadarChart";
interface Dimension {
id: string;
title: string;
}
interface EvalRow { interface EvalRow {
id: string; id: string;
@@ -12,8 +18,32 @@ interface EvalRow {
candidateTeam?: string | null; candidateTeam?: string | null;
evaluatorName: string; evaluatorName: string;
evaluationDate: string; evaluationDate: string;
template?: { name: string }; template?: { name: string; dimensions?: Dimension[] };
status: string; status: string;
dimensionScores?: { dimensionId: string; score: number | null; dimension?: { title: string } }[];
}
function buildRadarData(e: EvalRow) {
const dimensions = e.template?.dimensions ?? [];
const scoreMap = new Map(
(e.dimensionScores ?? []).map((ds) => [ds.dimensionId, ds])
);
return dimensions
.filter((dim) => !(dim.title ?? "").startsWith("[Optionnel]"))
.map((dim) => {
const ds = scoreMap.get(dim.id);
const score = ds?.score;
if (score == null) return null;
const s = Number(score);
if (Number.isNaN(s) || s < 0 || s > 5) return null;
const title = dim.title ?? "";
return {
dimension: title.length > 12 ? title.slice(0, 12) + "…" : title,
score: s,
fullMark: 5,
};
})
.filter((d): d is { dimension: string; score: number; fullMark: number } => d != null);
} }
export default function DashboardPage() { export default function DashboardPage() {
@@ -41,80 +71,77 @@ export default function DashboardPage() {
</Link> </Link>
</div> </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">Équipe</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 ? ( {loading ? (
<tr> <div className="py-12 text-center font-mono text-xs text-zinc-600 dark:text-zinc-500">loading...</div>
<td colSpan={8} className="px-4 py-12 text-center font-mono text-xs text-zinc-600 dark:text-zinc-500">
loading...
</td>
</tr>
) : evaluations.length === 0 ? ( ) : evaluations.length === 0 ? (
<tr> <div className="py-12 text-center text-zinc-600 dark:text-zinc-500">
<td colSpan={8} className="px-4 py-12 text-center text-zinc-600 dark:text-zinc-500">
Aucune évaluation.{" "} Aucune évaluation.{" "}
<Link href="/evaluations/new" className="text-cyan-600 dark:text-cyan-400 hover:underline"> <Link href="/evaluations/new" className="text-cyan-600 dark:text-cyan-400 hover:underline">
Créer Créer
</Link> </Link>
</td> </div>
</tr>
) : ( ) : (
evaluations.map((e) => ( <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<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"> {evaluations.map((e) => {
<td className="px-4 py-2.5 text-sm font-medium text-zinc-800 dark:text-zinc-100">{e.candidateName}</td> const radarData = buildRadarData(e);
<td className="px-4 py-2.5 text-sm text-zinc-600 dark:text-zinc-400">{e.candidateTeam ?? "—"}</td> return (
<td className="px-4 py-2.5 text-sm text-zinc-600 dark:text-zinc-400">{e.candidateRole}</td> <Link
<td className="px-4 py-2.5 text-sm text-zinc-600 dark:text-zinc-400">{e.evaluatorName}</td> key={e.id}
<td className="px-4 py-2.5 font-mono text-xs text-zinc-600 dark:text-zinc-400"> href={`/evaluations/${e.id}`}
{format(new Date(e.evaluationDate), "yyyy-MM-dd")} className="group flex flex-col overflow-hidden rounded-lg border border-zinc-200 dark:border-zinc-600 bg-white dark:bg-zinc-800 shadow-sm dark:shadow-none hover:border-cyan-500/50 dark:hover:border-cyan-500/30 transition-colors"
</td> >
<td className="px-4 py-2.5 text-sm text-zinc-600 dark:text-zinc-400">{e.template?.name ?? ""}</td> <div className="flex flex-1 flex-col p-4">
<td className="px-4 py-2.5"> <div className="mb-3 flex items-start justify-between gap-2">
<div className="min-w-0">
<h3 className="truncate font-medium text-zinc-800 dark:text-zinc-100">{e.candidateName}</h3>
<p className="truncate text-sm text-zinc-600 dark:text-zinc-400">
{e.candidateRole}
{e.candidateTeam && ` · ${e.candidateTeam}`}
</p>
</div>
<span <span
className={`font-mono text-xs px-1.5 py-0.5 rounded ${ className={`shrink-0 font-mono text-xs px-1.5 py-0.5 rounded ${
e.status === "submitted" ? "bg-emerald-500/20 text-emerald-600 dark:text-emerald-400" : "bg-amber-500/20 text-amber-600 dark:text-amber-400" e.status === "submitted" ? "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"} {e.status === "submitted" ? "ok" : "draft"}
</span> </span>
</td> </div>
<td className="px-4 py-2.5 text-right"> <div className="mb-3 flex flex-wrap gap-x-3 gap-y-0.5 font-mono text-xs text-zinc-500 dark:text-zinc-400">
<span className="inline-flex items-center gap-3"> <span>{e.evaluatorName}</span>
<Link <span>{format(new Date(e.evaluationDate), "yyyy-MM-dd")}</span>
href={`/evaluations/${e.id}`} <span>{e.template?.name ?? ""}</span>
className="font-mono text-xs text-cyan-600 dark:text-cyan-400 hover:text-cyan-500 dark:hover:text-cyan-300" </div>
> <div className="mt-auto min-h-[7rem]">
{radarData.length > 0 ? (
</Link> <RadarChart data={radarData} compact />
) : (
<div className="flex h-28 items-center justify-center rounded bg-zinc-50 dark:bg-zinc-700/30 font-mono text-xs text-zinc-400 dark:text-zinc-500">
pas de scores
</div>
)}
</div>
</div>
<div className="flex border-t border-zinc-200 dark:border-zinc-600 bg-zinc-50 dark:bg-zinc-700/30 px-4 py-2">
<span className="font-mono text-xs text-cyan-600 dark:text-cyan-400 group-hover:underline"> ouvrir</span>
<button <button
type="button" type="button"
onClick={() => setDeleteTarget(e)} onClick={(ev) => {
className="font-mono text-xs text-red-500 hover:text-red-400" ev.preventDefault();
ev.stopPropagation();
setDeleteTarget(e);
}}
className="ml-auto font-mono text-xs text-red-500 hover:text-red-400"
title="Supprimer" title="Supprimer"
> >
× supprimer
</button> </button>
</span>
</td>
</tr>
))
)}
</tbody>
</table>
</div> </div>
</Link>
);
})}
</div>
)}
<ConfirmModal <ConfirmModal
isOpen={!!deleteTarget} isOpen={!!deleteTarget}

View File

@@ -51,6 +51,8 @@ interface DimensionCardProps {
index: number; index: number;
evaluationId?: string; evaluationId?: string;
onScoreChange: (dimensionId: string, data: Partial<DimensionScore>) => void; onScoreChange: (dimensionId: string, data: Partial<DimensionScore>) => void;
/** Increment to collapse this card (e.g. from "Tout fermer" button) */
collapseAllTrigger?: number;
} }
function parseRubric(rubric: string): string[] { function parseRubric(rubric: string): string[] {
@@ -76,7 +78,7 @@ function parseQuestions(s: string | null | undefined): string[] {
const inputClass = 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"; "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, evaluationId, onScoreChange }: DimensionCardProps) { export function DimensionCard({ dimension, score, index, evaluationId, onScoreChange, collapseAllTrigger }: DimensionCardProps) {
const [notes, setNotes] = useState(score?.candidateNotes ?? ""); const [notes, setNotes] = useState(score?.candidateNotes ?? "");
const hasQuestions = parseQuestions(dimension.suggestedQuestions).length > 0; const hasQuestions = parseQuestions(dimension.suggestedQuestions).length > 0;
const [expanded, setExpanded] = useState(hasQuestions); const [expanded, setExpanded] = useState(hasQuestions);
@@ -88,6 +90,13 @@ export function DimensionCard({ dimension, score, index, evaluationId, onScoreCh
} }
}, [evaluationId, dimension.id]); }, [evaluationId, dimension.id]);
useEffect(() => {
if (collapseAllTrigger != null && collapseAllTrigger > 0) {
setExpanded(false);
if (evaluationId) setStoredExpanded(evaluationId, dimension.id, false);
}
}, [collapseAllTrigger, evaluationId, dimension.id]);
const toggleExpanded = () => { const toggleExpanded = () => {
setExpanded((e) => { setExpanded((e) => {
const next = !e; const next = !e;

View File

@@ -20,6 +20,8 @@ interface DataPoint {
interface RadarChartProps { interface RadarChartProps {
data: DataPoint[]; data: DataPoint[];
/** Compact mode for cards (smaller, no legend) */
compact?: boolean;
} }
const LIGHT = { const LIGHT = {
@@ -39,19 +41,19 @@ const DARK = {
tooltipText: "#fafafa", tooltipText: "#fafafa",
}; };
export function RadarChart({ data }: RadarChartProps) { export function RadarChart({ data, compact }: RadarChartProps) {
const { theme } = useTheme(); const { theme } = useTheme();
const c = theme === "dark" ? DARK : LIGHT; const c = theme === "dark" ? DARK : LIGHT;
if (data.length === 0) return null; if (data.length === 0) return null;
return ( return (
<div className="h-72 w-full"> <div className={compact ? "h-28 w-full" : "h-72 w-full"}>
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">
<RechartsRadar data={data}> <RechartsRadar data={data}>
<PolarGrid stroke={c.grid} /> <PolarGrid stroke={c.grid} />
<PolarAngleAxis dataKey="dimension" tick={{ fontSize: 9, fill: c.axis }} /> <PolarAngleAxis dataKey="dimension" tick={{ fontSize: compact ? 7 : 9, fill: c.axis }} />
<PolarRadiusAxis angle={30} domain={[0, 5]} tick={{ fill: c.tick }} /> <PolarRadiusAxis angle={30} domain={[0, 5]} tick={false} />
<Radar <Radar
name="Score" name="Score"
dataKey="score" dataKey="score"
@@ -68,7 +70,7 @@ export function RadarChart({ data }: RadarChartProps) {
fontSize: "12px", fontSize: "12px",
}} }}
/> />
<Legend wrapperStyle={{ fontSize: "11px" }} /> {!compact && <Legend wrapperStyle={{ fontSize: "11px" }} />}
</RechartsRadar> </RechartsRadar>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>