Files
iag-dev-evaluator/src/components/DimensionCard.tsx

243 lines
8.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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>
);
}