Integrate RadarChart component into DashboardPage, enhancing evaluation display with radar data visualization. Update API to include dimensions in template retrieval, and adjust RadarChart for compact mode support.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m7s
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m7s
This commit is contained in:
@@ -13,7 +13,7 @@ export async function GET(req: NextRequest) {
|
||||
...(templateId && { templateId }),
|
||||
},
|
||||
include: {
|
||||
template: true,
|
||||
template: { include: { dimensions: { orderBy: { orderIndex: "asc" } } } },
|
||||
dimensionScores: { include: { dimension: true } },
|
||||
},
|
||||
orderBy: { evaluationDate: "desc" },
|
||||
@@ -57,7 +57,7 @@ export async function POST(req: NextRequest) {
|
||||
status: "draft",
|
||||
},
|
||||
include: {
|
||||
template: true,
|
||||
template: { include: { dimensions: { orderBy: { orderIndex: "asc" } } } },
|
||||
dimensionScores: { include: { dimension: true } },
|
||||
},
|
||||
});
|
||||
@@ -75,7 +75,7 @@ export async function POST(req: NextRequest) {
|
||||
const updated = await prisma.evaluation.findUnique({
|
||||
where: { id: evaluation.id },
|
||||
include: {
|
||||
template: true,
|
||||
template: { include: { dimensions: { orderBy: { orderIndex: "asc" } } } },
|
||||
dimensionScores: { include: { dimension: true } },
|
||||
},
|
||||
});
|
||||
|
||||
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 { format } from "date-fns";
|
||||
import { ConfirmModal } from "@/components/ConfirmModal";
|
||||
import { RadarChart } from "@/components/RadarChart";
|
||||
|
||||
interface Dimension {
|
||||
id: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
interface EvalRow {
|
||||
id: string;
|
||||
@@ -12,8 +18,32 @@ interface EvalRow {
|
||||
candidateTeam?: string | null;
|
||||
evaluatorName: string;
|
||||
evaluationDate: string;
|
||||
template?: { name: 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);
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
@@ -41,80 +71,77 @@ export default function DashboardPage() {
|
||||
</Link>
|
||||
</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">
|
||||
<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">Candidat</th>
|
||||
<th className="px-4 py-2.5 text-left font-mono text-xs text-zinc-600 dark:text-zinc-400">Équipe</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">Évaluateur</th>
|
||||
<th className="px-4 py-2.5 text-left font-mono text-xs text-zinc-600 dark:text-zinc-400">Date</th>
|
||||
<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>
|
||||
<th className="px-4 py-2.5 text-right font-mono text-xs text-zinc-600 dark:text-zinc-400">—</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-4 py-12 text-center font-mono text-xs text-zinc-600 dark:text-zinc-500">
|
||||
loading...
|
||||
</td>
|
||||
</tr>
|
||||
) : evaluations.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-4 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>
|
||||
</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">
|
||||
{loading ? (
|
||||
<div className="py-12 text-center font-mono text-xs text-zinc-600 dark:text-zinc-500">loading...</div>
|
||||
) : evaluations.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">
|
||||
{evaluations.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={`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" ? "ok" : "draft"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right">
|
||||
<span className="inline-flex items-center gap-3">
|
||||
<Link
|
||||
href={`/evaluations/${e.id}`}
|
||||
className="font-mono text-xs text-cyan-600 dark:text-cyan-400 hover:text-cyan-500 dark:hover:text-cyan-300"
|
||||
>
|
||||
→
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setDeleteTarget(e)}
|
||||
className="font-mono text-xs text-red-500 hover:text-red-400"
|
||||
title="Supprimer"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</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}
|
||||
|
||||
@@ -20,6 +20,8 @@ interface DataPoint {
|
||||
|
||||
interface RadarChartProps {
|
||||
data: DataPoint[];
|
||||
/** Compact mode for cards (smaller, no legend) */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
const LIGHT = {
|
||||
@@ -39,19 +41,19 @@ const DARK = {
|
||||
tooltipText: "#fafafa",
|
||||
};
|
||||
|
||||
export function RadarChart({ data }: RadarChartProps) {
|
||||
export function RadarChart({ data, compact }: RadarChartProps) {
|
||||
const { theme } = useTheme();
|
||||
const c = theme === "dark" ? DARK : LIGHT;
|
||||
|
||||
if (data.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="h-72 w-full">
|
||||
<div className={compact ? "h-28 w-full" : "h-72 w-full"}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<RechartsRadar data={data}>
|
||||
<PolarGrid stroke={c.grid} />
|
||||
<PolarAngleAxis dataKey="dimension" tick={{ fontSize: 9, fill: c.axis }} />
|
||||
<PolarRadiusAxis angle={30} domain={[0, 5]} tick={{ fill: c.tick }} />
|
||||
<PolarAngleAxis dataKey="dimension" tick={{ fontSize: compact ? 7 : 9, fill: c.axis }} />
|
||||
<PolarRadiusAxis angle={30} domain={[0, 5]} tick={false} />
|
||||
<Radar
|
||||
name="Score"
|
||||
dataKey="score"
|
||||
@@ -68,7 +70,7 @@ export function RadarChart({ data }: RadarChartProps) {
|
||||
fontSize: "12px",
|
||||
}}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: "11px" }} />
|
||||
{!compact && <Legend wrapperStyle={{ fontSize: "11px" }} />}
|
||||
</RechartsRadar>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user