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:
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user