Files
iag-dev-evaluator/src/components/DimensionCard.tsx
Froidefond Julien cfde81b8de
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 7m6s
feat: auto-save ciblé au blur avec feedback violet sur tous les champs
- Nouvelle action updateDimensionScore pour sauvegarder un seul champ
  en base sans envoyer tout le formulaire
- DimensionCard : blur sur notes, justification, exemples, confiance
  → upsert ciblé + bordure violette 800ms
- CandidateForm : même pattern sur tous les champs du cartouche
- Bouton save passe aussi en violet (cohérence visuelle)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 08:29:51 +01:00

277 lines
11 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";
import { updateDimensionScore } from "@/actions/evaluations";
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 [dirtyFields, setDirtyFields] = useState<Record<string, boolean>>({});
const [savedField, setSavedField] = useState<string | null>(null);
const markDirty = (field: string) => {
setDirtyFields((p) => ({ ...p, [field]: true }));
setSavedField(null);
};
const saveOnBlur = (field: string, data: Parameters<typeof updateDimensionScore>[2]) => {
if (!dirtyFields[field] || !evaluationId) return;
setDirtyFields((p) => ({ ...p, [field]: false }));
setSavedField(field);
updateDimensionScore(evaluationId, dimension.id, data);
setTimeout(() => setSavedField((cur) => (cur === field ? null : cur)), 800);
};
const savedStyle = { borderColor: "#a855f7", boxShadow: "0 0 0 1px #a855f733" };
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);
markDirty("notes");
onScoreChange(dimension.id, { candidateNotes: e.target.value });
}}
onBlur={() => saveOnBlur("notes", { candidateNotes: notes })}
rows={2}
className={`${inputClass} transition-colors duration-200`}
style={savedField === "notes" ? savedStyle : undefined}
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) => {
markDirty("justification");
onScoreChange(dimension.id, { justification: e.target.value || null });
}}
onBlur={(e) => saveOnBlur("justification", { justification: e.target.value || null })}
className={`${inputClass} transition-colors duration-200`}
style={savedField === "justification" ? savedStyle : undefined}
placeholder="Courte..."
/>
</div>
<div>
<label className="text-xs text-zinc-500">Exemples</label>
<input
type="text"
value={score?.examplesObserved ?? ""}
onChange={(e) => {
markDirty("examples");
onScoreChange(dimension.id, { examplesObserved: e.target.value || null });
}}
onBlur={(e) => saveOnBlur("examples", { examplesObserved: e.target.value || null })}
className={`${inputClass} transition-colors duration-200`}
style={savedField === "examples" ? savedStyle : undefined}
placeholder="Concrets..."
/>
</div>
<div>
<label className="text-xs text-zinc-500">Confiance</label>
<select
value={score?.confidence ?? ""}
onChange={(e) => {
markDirty("confidence");
onScoreChange(dimension.id, { confidence: e.target.value || null });
}}
onBlur={(e) => saveOnBlur("confidence", { confidence: e.target.value || null })}
className={`${inputClass} transition-colors duration-200`}
style={savedField === "confidence" ? savedStyle : undefined}
>
<option value=""></option>
<option value="low">Faible</option>
<option value="med">Moyenne</option>
<option value="high">Haute</option>
</select>
</div>
</div>
</div>
)}
</div>
);
}