408 lines
16 KiB
TypeScript
408 lines
16 KiB
TypeScript
"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>
|
|
);
|
|
}
|