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
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m7s
This commit is contained in:
174
src/components/AdminClient.tsx
Normal file
174
src/components/AdminClient.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { format } from "date-fns";
|
||||
import { setUserRole, deleteUser } from "@/actions/admin";
|
||||
import { useSession } from "next-auth/react";
|
||||
import { ConfirmModal } from "@/components/ConfirmModal";
|
||||
|
||||
interface Template {
|
||||
id: string;
|
||||
name: string;
|
||||
dimensions: { id: string; title: string; orderIndex: number }[];
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string | null;
|
||||
role: string;
|
||||
createdAt: Date | string;
|
||||
}
|
||||
|
||||
interface AdminClientProps {
|
||||
templates: Template[];
|
||||
users: User[];
|
||||
}
|
||||
|
||||
export function AdminClient({ templates, users: initialUsers }: AdminClientProps) {
|
||||
const { data: session } = useSession();
|
||||
const [users, setUsers] = useState(initialUsers);
|
||||
const [updatingId, setUpdatingId] = useState<string | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<User | null>(null);
|
||||
|
||||
async function handleSetRole(userId: string, role: "admin" | "evaluator") {
|
||||
setUpdatingId(userId);
|
||||
try {
|
||||
const result = await setUserRole(userId, role);
|
||||
if (result.success) {
|
||||
setUsers((prev) => prev.map((u) => (u.id === userId ? { ...u, role } : u)));
|
||||
} else {
|
||||
alert(result.error);
|
||||
}
|
||||
} finally {
|
||||
setUpdatingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="mb-6 font-mono text-lg font-medium text-zinc-800 dark:text-zinc-200">Admin</h1>
|
||||
|
||||
<section>
|
||||
<h2 className="mb-4 font-mono text-xs text-zinc-600 dark:text-zinc-500">Users</h2>
|
||||
<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">Email</th>
|
||||
<th className="px-4 py-2.5 text-left font-mono text-xs text-zinc-600 dark:text-zinc-400">Nom</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">Créé le</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>
|
||||
{users.map((u) => (
|
||||
<tr key={u.id} className="border-b border-zinc-200 dark:border-zinc-600/50 last:border-0">
|
||||
<td className="px-4 py-2.5 text-sm text-zinc-800 dark:text-zinc-200">{u.email}</td>
|
||||
<td className="px-4 py-2.5 text-sm text-zinc-600 dark:text-zinc-400">{u.name ?? "—"}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<span
|
||||
className={`font-mono text-xs px-1.5 py-0.5 rounded ${
|
||||
u.role === "admin" ? "bg-cyan-500/20 text-cyan-600 dark:text-cyan-400" : "bg-zinc-200 dark:bg-zinc-700 text-zinc-600 dark:text-zinc-400"
|
||||
}`}
|
||||
>
|
||||
{u.role}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 font-mono text-xs text-zinc-500 dark:text-zinc-400">
|
||||
{format(new Date(u.createdAt), "yyyy-MM-dd HH:mm")}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right">
|
||||
<span className="inline-flex items-center gap-2">
|
||||
{u.role === "admin" ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSetRole(u.id, "evaluator")}
|
||||
disabled={updatingId === u.id}
|
||||
className="font-mono text-xs text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300 disabled:opacity-50"
|
||||
title="Rétrograder en évaluateur"
|
||||
>
|
||||
{updatingId === u.id ? "..." : "rétrograder"}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSetRole(u.id, "admin")}
|
||||
disabled={updatingId === u.id}
|
||||
className="font-mono text-xs text-cyan-600 dark:text-cyan-400 hover:text-cyan-500 disabled:opacity-50"
|
||||
title="Promouvoir admin"
|
||||
>
|
||||
{updatingId === u.id ? "..." : "promouvoir admin"}
|
||||
</button>
|
||||
)}
|
||||
{u.id !== session?.user?.id && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeleteTarget(u)}
|
||||
className="font-mono text-xs text-red-500 hover:text-red-400"
|
||||
title="Supprimer"
|
||||
>
|
||||
supprimer
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={!!deleteTarget}
|
||||
title="Supprimer l'utilisateur"
|
||||
message={
|
||||
deleteTarget
|
||||
? `Supprimer ${deleteTarget.name || deleteTarget.email} ? Les évaluations créées par cet utilisateur resteront (évaluateur mis à null).`
|
||||
: ""
|
||||
}
|
||||
confirmLabel="Supprimer"
|
||||
cancelLabel="Annuler"
|
||||
variant="danger"
|
||||
onConfirm={async () => {
|
||||
if (!deleteTarget) return;
|
||||
const result = await deleteUser(deleteTarget.id);
|
||||
if (result.success) {
|
||||
setUsers((prev) => prev.filter((u) => u.id !== deleteTarget.id));
|
||||
setDeleteTarget(null);
|
||||
} else {
|
||||
alert(result.error);
|
||||
}
|
||||
}}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
|
||||
<section className="mt-8">
|
||||
<h2 className="mb-4 font-mono text-xs text-zinc-600 dark:text-zinc-500">Modèles</h2>
|
||||
<div className="space-y-3">
|
||||
{templates.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className="rounded-lg border border-zinc-200 dark:border-zinc-600 bg-white dark:bg-zinc-800 p-4 shadow-sm dark:shadow-none"
|
||||
>
|
||||
<h3 className="font-medium text-zinc-800 dark:text-zinc-200">{t.name}</h3>
|
||||
<p className="mt-1 font-mono text-xs text-zinc-600 dark:text-zinc-500">
|
||||
{t.dimensions.length} dim.
|
||||
</p>
|
||||
<ul className="mt-2 space-y-0.5 font-mono text-xs text-zinc-600 dark:text-zinc-400">
|
||||
{t.dimensions.slice(0, 5).map((d) => (
|
||||
<li key={d.id}>• {d.title}</li>
|
||||
))}
|
||||
{t.dimensions.length > 5 && (
|
||||
<li className="text-zinc-600 dark:text-zinc-500">+{t.dimensions.length - 5}</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
161
src/components/DashboardClient.tsx
Normal file
161
src/components/DashboardClient.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { deleteEvaluation } from "@/actions/evaluations";
|
||||
import { format } from "date-fns";
|
||||
import { ConfirmModal } from "@/components/ConfirmModal";
|
||||
import { RadarChart } from "@/components/RadarChart";
|
||||
|
||||
interface Dimension {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface EvalRow {
|
||||
id: string;
|
||||
candidateName: string;
|
||||
candidateRole: string;
|
||||
candidateTeam?: string | null;
|
||||
evaluatorName: string;
|
||||
evaluationDate: string;
|
||||
template?: { name: string; dimensions?: Dimension[] };
|
||||
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);
|
||||
}
|
||||
|
||||
interface DashboardClientProps {
|
||||
evaluations: EvalRow[];
|
||||
}
|
||||
|
||||
export function DashboardClient({ evaluations }: DashboardClientProps) {
|
||||
const [list, setList] = useState(evaluations);
|
||||
const [deleteTarget, setDeleteTarget] = useState<EvalRow | null>(null);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h1 className="font-mono text-lg font-medium text-zinc-800 dark:text-zinc-100">Évaluations</h1>
|
||||
<Link
|
||||
href="/evaluations/new"
|
||||
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 transition-colors"
|
||||
>
|
||||
+ nouvelle
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{list.length === 0 ? (
|
||||
<div className="py-12 text-center text-zinc-600 dark:text-zinc-500">
|
||||
Aucune évaluation.{" "}
|
||||
<Link href="/evaluations/new" className="text-cyan-600 dark:text-cyan-400 hover:underline">
|
||||
Créer
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{list.map((e) => {
|
||||
const radarData = buildRadarData(e);
|
||||
return (
|
||||
<Link
|
||||
key={e.id}
|
||||
href={`/evaluations/${e.id}`}
|
||||
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"
|
||||
>
|
||||
<div className="flex flex-1 flex-col p-4">
|
||||
<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
|
||||
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" ? "ok" : "draft"}
|
||||
</span>
|
||||
</div>
|
||||
<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>{e.evaluatorName}</span>
|
||||
<span>{format(new Date(e.evaluationDate), "yyyy-MM-dd")}</span>
|
||||
<span>{e.template?.name ?? ""}</span>
|
||||
</div>
|
||||
<div className="mt-auto min-h-[7rem]">
|
||||
{radarData.length > 0 ? (
|
||||
<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
|
||||
type="button"
|
||||
onClick={(ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
setDeleteTarget(e);
|
||||
}}
|
||||
className="ml-auto font-mono text-xs text-red-500 hover:text-red-400"
|
||||
title="Supprimer"
|
||||
>
|
||||
supprimer
|
||||
</button>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={!!deleteTarget}
|
||||
title="Supprimer l'évaluation"
|
||||
message={
|
||||
deleteTarget
|
||||
? `Supprimer l'évaluation de ${deleteTarget.candidateName} ? Cette action est irréversible.`
|
||||
: ""
|
||||
}
|
||||
confirmLabel="Supprimer"
|
||||
cancelLabel="Annuler"
|
||||
variant="danger"
|
||||
onConfirm={async () => {
|
||||
if (!deleteTarget) return;
|
||||
const result = await deleteEvaluation(deleteTarget.id);
|
||||
if (result.success) setList((prev) => prev.filter((x) => x.id !== deleteTarget.id));
|
||||
else alert(result.error);
|
||||
}}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
407
src/components/EvaluationEditor.tsx
Normal file
407
src/components/EvaluationEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
75
src/components/NewEvaluationForm.tsx
Normal file
75
src/components/NewEvaluationForm.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { createEvaluation } from "@/actions/evaluations";
|
||||
import { CandidateForm } from "@/components/CandidateForm";
|
||||
|
||||
interface NewEvaluationFormProps {
|
||||
templates: { id: string; name: string }[];
|
||||
initialEvaluatorName: string;
|
||||
}
|
||||
|
||||
export function NewEvaluationForm({ templates, initialEvaluatorName }: NewEvaluationFormProps) {
|
||||
const router = useRouter();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
candidateName: "",
|
||||
candidateRole: "",
|
||||
candidateTeam: "",
|
||||
evaluatorName: initialEvaluatorName,
|
||||
evaluationDate: new Date().toISOString().split("T")[0],
|
||||
templateId: templates[0]?.id ?? "",
|
||||
});
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!form.templateId) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const result = await createEvaluation({
|
||||
...form,
|
||||
evaluationDate: new Date(form.evaluationDate).toISOString(),
|
||||
});
|
||||
if (result.success && result.data) {
|
||||
router.push(`/evaluations/${result.data.id}`);
|
||||
} else if (!result.success) {
|
||||
alert(result.error);
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="mb-6 font-mono text-lg font-medium text-zinc-800 dark:text-zinc-200">Nouvelle évaluation</h1>
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
<div 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
|
||||
{...form}
|
||||
templates={templates}
|
||||
onChange={(field, value) => setForm((f) => ({ ...f, [field]: value }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={saving || !form.templateId}
|
||||
className="rounded border border-cyan-500/50 bg-cyan-500/20 px-4 py-2 font-mono text-sm text-cyan-600 dark:text-cyan-400 hover:bg-cyan-500/30 disabled:opacity-50"
|
||||
>
|
||||
{saving ? "..." : "créer →"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.back()}
|
||||
className="rounded border border-zinc-300 dark:border-zinc-700 px-4 py-2 font-mono text-sm text-zinc-600 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800"
|
||||
>
|
||||
annuler
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
122
src/components/SettingsPasswordForm.tsx
Normal file
122
src/components/SettingsPasswordForm.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { changePassword } from "@/actions/password";
|
||||
|
||||
export function SettingsPasswordForm() {
|
||||
const [currentPassword, setCurrentPassword] = useState("");
|
||||
const [newPassword, setNewPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError("");
|
||||
setSuccess(false);
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError("Les deux nouveaux mots de passe ne correspondent pas");
|
||||
return;
|
||||
}
|
||||
if (newPassword.length < 8) {
|
||||
setError("Le mot de passe doit faire au moins 8 caractères");
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const result = await changePassword(currentPassword, newPassword);
|
||||
if (result.success) {
|
||||
setSuccess(true);
|
||||
setCurrentPassword("");
|
||||
setNewPassword("");
|
||||
setConfirmPassword("");
|
||||
} else {
|
||||
setError(result.error);
|
||||
}
|
||||
} catch {
|
||||
setError("Erreur de connexion");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-md">
|
||||
<h1 className="mb-6 font-mono text-lg font-medium text-zinc-800 dark:text-zinc-100">
|
||||
Paramètres
|
||||
</h1>
|
||||
|
||||
<section className="rounded-lg border border-zinc-200 dark:border-zinc-600 bg-white dark:bg-zinc-800/50 p-4">
|
||||
<h2 className="mb-4 font-mono text-sm font-medium text-zinc-700 dark:text-zinc-300">
|
||||
Changer mon mot de passe
|
||||
</h2>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block font-mono text-xs text-zinc-600 dark:text-zinc-400">
|
||||
Mot de passe actuel
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={currentPassword}
|
||||
onChange={(e) => setCurrentPassword(e.target.value)}
|
||||
required
|
||||
autoComplete="current-password"
|
||||
className="w-full rounded border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-800 px-3 py-2 text-sm text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500/30"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block font-mono text-xs text-zinc-600 dark:text-zinc-400">
|
||||
Nouveau mot de passe
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
autoComplete="new-password"
|
||||
className="w-full rounded border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-800 px-3 py-2 text-sm text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500/30"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block font-mono text-xs text-zinc-600 dark:text-zinc-400">
|
||||
Confirmer le nouveau mot de passe
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
required
|
||||
minLength={8}
|
||||
autoComplete="new-password"
|
||||
className="w-full rounded border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-800 px-3 py-2 text-sm text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500/30"
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="font-mono text-xs text-red-500">{error}</p>
|
||||
)}
|
||||
{success && (
|
||||
<p className="font-mono text-xs text-emerald-600 dark:text-emerald-400">
|
||||
Mot de passe modifié.
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="rounded border border-cyan-500/50 bg-cyan-500/10 px-3 py-2 font-mono text-sm text-cyan-600 dark:text-cyan-400 hover:bg-cyan-500/20 disabled:opacity-50"
|
||||
>
|
||||
{loading ? "..." : "Modifier le mot de passe"}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<p className="mt-6 font-mono text-xs text-zinc-500 dark:text-zinc-400">
|
||||
<Link href="/dashboard" className="text-cyan-600 dark:text-cyan-400 hover:underline">
|
||||
← Retour au dashboard
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { addShare, removeShare } from "@/actions/share";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
@@ -45,17 +46,12 @@ export function ShareModal({
|
||||
if (!shareUserId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`/api/evaluations/${evaluationId}/share`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ userId: shareUserId }),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (res.ok) {
|
||||
const result = await addShare(evaluationId, shareUserId);
|
||||
if (result.success) {
|
||||
setShareUserId("");
|
||||
onUpdate();
|
||||
} else {
|
||||
alert(data.error ?? "Erreur");
|
||||
alert(result.error);
|
||||
}
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -63,10 +59,8 @@ export function ShareModal({
|
||||
}
|
||||
|
||||
async function handleRemove(userId: string) {
|
||||
const res = await fetch(`/api/evaluations/${evaluationId}/share/${userId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
if (res.ok) onUpdate();
|
||||
const result = await removeShare(evaluationId, userId);
|
||||
if (result.success) onUpdate();
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
Reference in New Issue
Block a user