Refactor evaluation and admin pages to use server actions for data fetching, enhancing performance and simplifying state management. Update README to reflect API route changes and remove deprecated API endpoints for users and evaluations.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m7s

This commit is contained in:
Julien Froidefond
2026-02-20 14:08:18 +01:00
parent 2ef9b4d6f9
commit aab8a192d4
28 changed files with 1511 additions and 1739 deletions

View File

@@ -0,0 +1,407 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { updateEvaluation, deleteEvaluation, fetchEvaluation } from "@/actions/evaluations";
import { CandidateForm } from "@/components/CandidateForm";
import { DimensionCard } from "@/components/DimensionCard";
import { RadarChart } from "@/components/RadarChart";
import { ExportModal } from "@/components/ExportModal";
import { ShareModal } from "@/components/ShareModal";
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;
candidateTeam?: string | null;
evaluatorName: string;
evaluatorId?: string | null;
evaluationDate: string;
templateId: string;
template: { id: string; name: string; dimensions: Dimension[] };
status: string;
findings: string | null;
recommendations: string | null;
dimensionScores: DimensionScore[];
sharedWith?: { id: string; user: { id: string; email: string; name: string | null } }[];
isPublic?: boolean;
}
interface EvaluationEditorProps {
id: string;
initialEvaluation: Evaluation;
templates: { id: string; name: string; dimensions?: { id: string; suggestedQuestions?: string | null }[] }[];
users: { id: string; email: string; name: string | null }[];
}
export function EvaluationEditor({ id, initialEvaluation, templates, users }: EvaluationEditorProps) {
const router = useRouter();
const [evaluation, setEvaluation] = useState<Evaluation>(initialEvaluation);
const [saving, setSaving] = useState(false);
const [exportOpen, setExportOpen] = useState(false);
const [shareOpen, setShareOpen] = useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [collapseAllTrigger, setCollapseAllTrigger] = useState(0);
const fetchEval = useCallback(async () => {
const result = await fetchEvaluation(id);
if (result.success && result.data) {
const d = result.data;
setEvaluation({ ...d, dimensionScores: d.dimensionScores ?? [] } as Evaluation);
}
}, [id]);
// Draft backup to localStorage (debounced)
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) => {
setEvaluation((e) => (e ? { ...e, [field]: value } : null!));
};
const handleScoreChange = (dimensionId: string, data: Partial<DimensionScore>) => {
setEvaluation((e) => {
if (!e) return null!;
const existing = e.dimensionScores.find((ds) => ds.dimensionId === dimensionId);
const dim = e.template?.dimensions?.find((d) => d.id === dimensionId);
const scores = existing
? e.dimensionScores.map((ds) =>
ds.dimensionId === dimensionId ? { ...ds, ...data } : ds
)
: [
...e.dimensionScores,
{
id: `temp-${dimensionId}`,
dimensionId,
score: (data as { score?: number }).score ?? null,
justification: (data as { justification?: string }).justification ?? null,
examplesObserved: (data as { examplesObserved?: string }).examplesObserved ?? null,
confidence: (data as { confidence?: string }).confidence ?? null,
candidateNotes: (data as { candidateNotes?: string }).candidateNotes ?? null,
dimension: dim ?? { id: dimensionId, slug: "", title: "", rubric: "" },
},
];
const next = { ...e, dimensionScores: scores };
if (data.score !== undefined) {
setTimeout(() => handleSave(next, { skipRefresh: true }), 0);
}
return next;
});
};
const handleSave = async (evalOverride?: Evaluation | null, options?: { skipRefresh?: boolean }) => {
const toSave = evalOverride ?? evaluation;
if (!toSave) return;
setSaving(true);
try {
const result = await updateEvaluation(id, {
candidateName: toSave.candidateName,
candidateRole: toSave.candidateRole,
candidateTeam: toSave.candidateTeam ?? null,
evaluatorName: toSave.evaluatorName,
evaluationDate: typeof toSave.evaluationDate === "string" ? toSave.evaluationDate : new Date(toSave.evaluationDate).toISOString(),
status: toSave.status,
findings: toSave.findings,
recommendations: toSave.recommendations,
isPublic: toSave.isPublic ?? false,
dimensionScores: (toSave.dimensionScores ?? []).map((ds) => ({
dimensionId: ds.dimensionId,
evaluationId: id,
score: ds.score,
justification: ds.justification,
examplesObserved: ds.examplesObserved,
confidence: ds.confidence,
candidateNotes: ds.candidateNotes,
})),
});
if (result.success) {
if (!options?.skipRefresh) fetchEval();
} else {
alert(result.error);
}
} catch (err) {
console.error("Save error:", err);
alert("Erreur lors de la sauvegarde");
} finally {
setSaving(false);
}
};
const handleGenerateFindings = () => {
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";
const dimensions = evaluation.template?.dimensions ?? [];
const dimensionScores = evaluation.dimensionScores ?? [];
const scoreMap = new Map(dimensionScores.map((ds) => [ds.dimensionId, ds]));
const radarData = 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 title = dim.title ?? "";
const s = Number(score);
if (Number.isNaN(s) || s < 0 || s > 5) return null;
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);
const avgScore = computeAverageScore(dimensionScores);
const templatesForForm = templates.map((t) => ({ id: t.id, name: t.name }));
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}
{evaluation.candidateTeam && (
<span className="text-zinc-500"> ({evaluation.candidateTeam})</span>
)}
<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={() => {
const next = !(evaluation.isPublic ?? false);
setEvaluation((e) => (e ? { ...e, isPublic: next } : null!));
handleSave(evaluation ? { ...evaluation, isPublic: next } : null);
}}
className={`rounded border px-3 py-1.5 font-mono text-xs ${
evaluation.isPublic
? "border-emerald-500/50 bg-emerald-500/20 text-emerald-600 dark:text-emerald-400"
: "border-zinc-300 dark:border-zinc-600 bg-zinc-100 dark:bg-zinc-700 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-200 dark:hover:bg-zinc-600"
}`}
>
{evaluation.isPublic ? "publique" : "rendre publique"}
</button>
<button
onClick={() => setShareOpen(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"
>
partager
</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="relative overflow-hidden rounded-xl border border-zinc-200 dark:border-zinc-600 bg-gradient-to-br from-zinc-50 to-white dark:from-zinc-800/80 dark:to-zinc-800 p-5 shadow-sm dark:shadow-none">
<div className="absolute left-0 top-0 h-full w-1 bg-gradient-to-b from-cyan-500/60 to-cyan-400/40" aria-hidden />
<h2 className="mb-4 font-mono text-xs font-medium uppercase tracking-wider text-zinc-500 dark:text-zinc-400">
Session
</h2>
<CandidateForm
candidateName={evaluation.candidateName}
candidateRole={evaluation.candidateRole}
candidateTeam={evaluation.candidateTeam ?? ""}
evaluatorName={evaluation.evaluatorName}
evaluationDate={evaluation.evaluationDate.split("T")[0]}
templateId={evaluation.templateId}
templates={templatesForForm}
onChange={handleFormChange}
templateDisabled
/>
</section>
<section>
<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">
{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}
evaluationId={id}
score={scoreMap.get(dim.id) ?? null}
onScoreChange={handleScoreChange}
collapseAllTrigger={collapseAllTrigger}
/>
</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={() => {
const updated = { ...evaluation, status: "submitted" };
setEvaluation(updated);
handleSave(updated);
}}
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("/dashboard")} 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}
/>
<ShareModal
isOpen={shareOpen}
onClose={() => setShareOpen(false)}
evaluationId={id}
evaluatorId={evaluation.evaluatorId}
users={users}
sharedWith={evaluation.sharedWith ?? []}
onUpdate={fetchEval}
/>
<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 result = await deleteEvaluation(id);
if (result.success) router.push("/dashboard");
else alert(result.error);
}}
onCancel={() => setDeleteConfirmOpen(false)}
/>
</div>
);
}