Compare commits
2 Commits
7a0cf76c18
...
34b2a8c5cc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34b2a8c5cc | ||
|
|
edb8125e56 |
@@ -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 } },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
165
src/app/page.tsx
165
src/app/page.tsx
@@ -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">
|
{loading ? (
|
||||||
<table className="min-w-full">
|
<div className="py-12 text-center font-mono text-xs text-zinc-600 dark:text-zinc-500">loading...</div>
|
||||||
<thead>
|
) : evaluations.length === 0 ? (
|
||||||
<tr className="border-b border-zinc-200 dark:border-zinc-600 bg-zinc-50 dark:bg-zinc-700/80">
|
<div className="py-12 text-center text-zinc-600 dark:text-zinc-500">
|
||||||
<th className="px-4 py-2.5 text-left font-mono text-xs text-zinc-600 dark:text-zinc-400">Candidat</th>
|
Aucune évaluation.{" "}
|
||||||
<th className="px-4 py-2.5 text-left font-mono text-xs text-zinc-600 dark:text-zinc-400">Équipe</th>
|
<Link href="/evaluations/new" className="text-cyan-600 dark:text-cyan-400 hover:underline">
|
||||||
<th className="px-4 py-2.5 text-left font-mono text-xs text-zinc-600 dark:text-zinc-400">Rôle</th>
|
Créer
|
||||||
<th className="px-4 py-2.5 text-left font-mono text-xs text-zinc-600 dark:text-zinc-400">Évaluateur</th>
|
</Link>
|
||||||
<th className="px-4 py-2.5 text-left font-mono text-xs text-zinc-600 dark:text-zinc-400">Date</th>
|
</div>
|
||||||
<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>
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
<th className="px-4 py-2.5 text-right font-mono text-xs text-zinc-600 dark:text-zinc-400">—</th>
|
{evaluations.map((e) => {
|
||||||
</tr>
|
const radarData = buildRadarData(e);
|
||||||
</thead>
|
return (
|
||||||
<tbody>
|
<Link
|
||||||
{loading ? (
|
key={e.id}
|
||||||
<tr>
|
href={`/evaluations/${e.id}`}
|
||||||
<td colSpan={8} className="px-4 py-12 text-center font-mono text-xs text-zinc-600 dark:text-zinc-500">
|
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"
|
||||||
loading...
|
>
|
||||||
</td>
|
<div className="flex flex-1 flex-col p-4">
|
||||||
</tr>
|
<div className="mb-3 flex items-start justify-between gap-2">
|
||||||
) : evaluations.length === 0 ? (
|
<div className="min-w-0">
|
||||||
<tr>
|
<h3 className="truncate font-medium text-zinc-800 dark:text-zinc-100">{e.candidateName}</h3>
|
||||||
<td colSpan={8} className="px-4 py-12 text-center text-zinc-600 dark:text-zinc-500">
|
<p className="truncate text-sm text-zinc-600 dark:text-zinc-400">
|
||||||
Aucune évaluation.{" "}
|
{e.candidateRole}
|
||||||
<Link href="/evaluations/new" className="text-cyan-600 dark:text-cyan-400 hover:underline">
|
{e.candidateTeam && ` · ${e.candidateTeam}`}
|
||||||
Créer
|
</p>
|
||||||
</Link>
|
</div>
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
|
||||||
evaluations.map((e) => (
|
|
||||||
<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">
|
|
||||||
<td className="px-4 py-2.5 text-sm font-medium text-zinc-800 dark:text-zinc-100">{e.candidateName}</td>
|
|
||||||
<td className="px-4 py-2.5 text-sm text-zinc-600 dark:text-zinc-400">{e.candidateTeam ?? "—"}</td>
|
|
||||||
<td className="px-4 py-2.5 text-sm text-zinc-600 dark:text-zinc-400">{e.candidateRole}</td>
|
|
||||||
<td className="px-4 py-2.5 text-sm text-zinc-600 dark:text-zinc-400">{e.evaluatorName}</td>
|
|
||||||
<td className="px-4 py-2.5 font-mono text-xs text-zinc-600 dark:text-zinc-400">
|
|
||||||
{format(new Date(e.evaluationDate), "yyyy-MM-dd")}
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-2.5 text-sm text-zinc-600 dark:text-zinc-400">{e.template?.name ?? ""}</td>
|
|
||||||
<td className="px-4 py-2.5">
|
|
||||||
<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 />
|
||||||
<button
|
) : (
|
||||||
type="button"
|
<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">
|
||||||
onClick={() => setDeleteTarget(e)}
|
pas de scores
|
||||||
className="font-mono text-xs text-red-500 hover:text-red-400"
|
</div>
|
||||||
title="Supprimer"
|
)}
|
||||||
>
|
</div>
|
||||||
×
|
</div>
|
||||||
</button>
|
<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>
|
<span className="font-mono text-xs text-cyan-600 dark:text-cyan-400 group-hover:underline">→ ouvrir</span>
|
||||||
</td>
|
<button
|
||||||
</tr>
|
type="button"
|
||||||
))
|
onClick={(ev) => {
|
||||||
)}
|
ev.preventDefault();
|
||||||
</tbody>
|
ev.stopPropagation();
|
||||||
</table>
|
setDeleteTarget(e);
|
||||||
</div>
|
}}
|
||||||
|
className="ml-auto font-mono text-xs text-red-500 hover:text-red-400"
|
||||||
|
title="Supprimer"
|
||||||
|
>
|
||||||
|
supprimer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
isOpen={!!deleteTarget}
|
isOpen={!!deleteTarget}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user