All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m17s
243 lines
8.9 KiB
TypeScript
243 lines
8.9 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useEffect } from "react";
|
||
|
||
const STORAGE_KEY_PREFIX = "eval-dim-expanded";
|
||
|
||
function getStoredExpanded(evaluationId: string, dimensionId: string): boolean | null {
|
||
if (typeof window === "undefined") return null;
|
||
try {
|
||
const raw = localStorage.getItem(`${STORAGE_KEY_PREFIX}-${evaluationId}`);
|
||
if (!raw) return null;
|
||
const obj = JSON.parse(raw) as Record<string, boolean>;
|
||
return obj[dimensionId] ?? null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function setStoredExpanded(evaluationId: string, dimensionId: string, expanded: boolean): void {
|
||
if (typeof window === "undefined") return;
|
||
try {
|
||
const key = `${STORAGE_KEY_PREFIX}-${evaluationId}`;
|
||
const raw = localStorage.getItem(key);
|
||
const obj = (raw ? JSON.parse(raw) : {}) as Record<string, boolean>;
|
||
obj[dimensionId] = expanded;
|
||
localStorage.setItem(key, JSON.stringify(obj));
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
|
||
interface Dimension {
|
||
id: string;
|
||
slug: string;
|
||
title: string;
|
||
rubric: string;
|
||
suggestedQuestions?: string | null;
|
||
}
|
||
|
||
interface DimensionScore {
|
||
score: number | null;
|
||
justification: string | null;
|
||
examplesObserved: string | null;
|
||
confidence: string | null;
|
||
candidateNotes: string | null;
|
||
}
|
||
|
||
interface DimensionCardProps {
|
||
dimension: Dimension;
|
||
score: DimensionScore | null;
|
||
index: number;
|
||
evaluationId?: string;
|
||
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[] {
|
||
if (rubric === "1-5" || !rubric) return ["1", "2", "3", "4", "5"];
|
||
const labels: string[] = [];
|
||
for (let i = 1; i <= 5; i++) {
|
||
const m = rubric.match(new RegExp(`${i}:([^;]+)`));
|
||
labels.push(m ? m[1].trim() : String(i));
|
||
}
|
||
return labels;
|
||
}
|
||
|
||
function parseQuestions(s: string | null | undefined): string[] {
|
||
if (!s) return [];
|
||
try {
|
||
const arr = JSON.parse(s) as unknown;
|
||
return Array.isArray(arr) ? arr.filter((x): x is string => typeof x === "string") : [];
|
||
} catch {
|
||
return [];
|
||
}
|
||
}
|
||
|
||
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";
|
||
|
||
export function DimensionCard({ dimension, score, index, evaluationId, onScoreChange, collapseAllTrigger }: DimensionCardProps) {
|
||
const [notes, setNotes] = useState(score?.candidateNotes ?? "");
|
||
const hasQuestions = parseQuestions(dimension.suggestedQuestions).length > 0;
|
||
const [expanded, setExpanded] = useState(hasQuestions);
|
||
|
||
useEffect(() => {
|
||
if (evaluationId && typeof window !== "undefined") {
|
||
const stored = getStoredExpanded(evaluationId, dimension.id);
|
||
if (stored !== null) queueMicrotask(() => setExpanded(stored));
|
||
}
|
||
}, [evaluationId, dimension.id]);
|
||
|
||
useEffect(() => {
|
||
if (collapseAllTrigger != null && collapseAllTrigger > 0) {
|
||
queueMicrotask(() => setExpanded(false));
|
||
if (evaluationId) setStoredExpanded(evaluationId, dimension.id, false);
|
||
}
|
||
}, [collapseAllTrigger, evaluationId, dimension.id]);
|
||
|
||
const toggleExpanded = () => {
|
||
setExpanded((e) => {
|
||
const next = !e;
|
||
if (evaluationId) setStoredExpanded(evaluationId, dimension.id, next);
|
||
return next;
|
||
});
|
||
};
|
||
const currentScore = score?.score ?? null;
|
||
const rubricLabels = parseRubric(dimension.rubric);
|
||
const questions = parseQuestions(dimension.suggestedQuestions);
|
||
|
||
return (
|
||
<div className="rounded-lg border border-zinc-200 dark:border-zinc-600 bg-white dark:bg-zinc-800 shadow-sm dark:shadow-none overflow-hidden">
|
||
<button
|
||
type="button"
|
||
onClick={toggleExpanded}
|
||
className="flex w-full items-center justify-between gap-4 px-4 py-3 text-left hover:bg-zinc-50 dark:hover:bg-zinc-700/50 transition-colors"
|
||
>
|
||
<div className="flex items-center gap-3 min-w-0">
|
||
<span className="font-mono text-xs text-zinc-500 tabular-nums w-5">{index + 1}.</span>
|
||
<span className="font-medium text-zinc-800 dark:text-zinc-100 truncate">{dimension.title}</span>
|
||
{currentScore != null && (
|
||
<span
|
||
className={`shrink-0 font-mono text-xs px-1.5 py-0.5 rounded ${
|
||
currentScore <= 2 ? "bg-amber-500/20 text-amber-600 dark:text-amber-400" :
|
||
currentScore >= 4 ? "bg-emerald-500/20 text-emerald-600 dark:text-emerald-400" :
|
||
"bg-zinc-300 dark:bg-zinc-700 text-zinc-700 dark:text-zinc-300"
|
||
}`}
|
||
>
|
||
{currentScore}/5
|
||
</span>
|
||
)}
|
||
</div>
|
||
<span className="text-zinc-500 text-sm">{expanded ? "−" : "+"}</span>
|
||
</button>
|
||
|
||
{expanded && (
|
||
<div className="border-t border-zinc-200 dark:border-zinc-600 px-4 py-3 space-y-3">
|
||
{/* Questions suggérées - en premier */}
|
||
{questions.length > 0 && (
|
||
<div className="rounded bg-zinc-50 dark:bg-zinc-700/80 p-2.5">
|
||
<p className="mb-2 text-xs font-medium text-zinc-500">Questions suggérées</p>
|
||
<ol className="list-decimal list-inside space-y-1 text-sm text-zinc-700 dark:text-zinc-200">
|
||
{questions.map((q, i) => (
|
||
<li key={i}>{q}</li>
|
||
))}
|
||
</ol>
|
||
</div>
|
||
)}
|
||
|
||
{/* Rubric */}
|
||
<div className="rounded bg-zinc-50 dark:bg-zinc-700/80 p-2.5 font-mono text-xs">
|
||
{rubricLabels.map((r, i) => (
|
||
<div key={i} className="text-zinc-600 dark:text-zinc-300">
|
||
<span className="text-cyan-600 dark:text-cyan-500/80">{i + 1}</span> {r}
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
{/* Quick score */}
|
||
<div className="flex items-center gap-1">
|
||
<span className="text-xs text-zinc-500 mr-2">Score:</span>
|
||
{[1, 2, 3, 4, 5].map((n) => (
|
||
<button
|
||
key={n}
|
||
type="button"
|
||
onClick={() => onScoreChange(dimension.id, { score: n })}
|
||
className={`w-8 h-8 rounded font-mono text-sm font-medium transition-colors ${
|
||
currentScore === n
|
||
? "bg-cyan-500 text-white"
|
||
: "bg-zinc-200 dark:bg-zinc-600 text-zinc-600 dark:text-zinc-300 hover:bg-zinc-300 dark:hover:bg-zinc-500 hover:text-zinc-800 dark:hover:text-zinc-100"
|
||
}`}
|
||
>
|
||
{n}
|
||
</button>
|
||
))}
|
||
{currentScore != null && (
|
||
<button
|
||
type="button"
|
||
onClick={() => onScoreChange(dimension.id, { score: null })}
|
||
className="ml-1 text-xs text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300"
|
||
>
|
||
reset
|
||
</button>
|
||
)}
|
||
</div>
|
||
|
||
{/* Notes candidat */}
|
||
<div>
|
||
<label className="text-xs text-zinc-500 mb-1 block">Notes candidat</label>
|
||
<textarea
|
||
value={notes}
|
||
onChange={(e) => {
|
||
setNotes(e.target.value);
|
||
onScoreChange(dimension.id, { candidateNotes: e.target.value });
|
||
}}
|
||
rows={2}
|
||
className={inputClass}
|
||
placeholder="Réponses du candidat..."
|
||
/>
|
||
</div>
|
||
|
||
{/* Justification, exemples, confiance */}
|
||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||
<div>
|
||
<label className="text-xs text-zinc-500">Justification</label>
|
||
<input
|
||
type="text"
|
||
value={score?.justification ?? ""}
|
||
onChange={(e) => onScoreChange(dimension.id, { justification: e.target.value || null })}
|
||
className={inputClass}
|
||
placeholder="Courte..."
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="text-xs text-zinc-500">Exemples</label>
|
||
<input
|
||
type="text"
|
||
value={score?.examplesObserved ?? ""}
|
||
onChange={(e) => onScoreChange(dimension.id, { examplesObserved: e.target.value || null })}
|
||
className={inputClass}
|
||
placeholder="Concrets..."
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="text-xs text-zinc-500">Confiance</label>
|
||
<select
|
||
value={score?.confidence ?? ""}
|
||
onChange={(e) => onScoreChange(dimension.id, { confidence: e.target.value || null })}
|
||
className={inputClass}
|
||
>
|
||
<option value="">—</option>
|
||
<option value="low">Faible</option>
|
||
<option value="med">Moyenne</option>
|
||
<option value="high">Haute</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|