Compare commits

..

3 Commits

Author SHA1 Message Date
cfde81b8de feat: auto-save ciblé au blur avec feedback violet sur tous les champs
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 7m6s
- 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
437b5db1da feat: auto-save notes candidat au blur avec indicateur de modification
Le champ "Notes candidat" passe en bordure ambre tant qu'il y a des
changements non sauvegardés. Au défocus, la sauvegarde se déclenche
automatiquement et la bordure revient à la normale.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 08:17:58 +01:00
c1751a1ab6 feat: animation de confirmation sur le bouton save
Ajoute 3 états visuels au bouton save : repos (gris), saving (spinner),
saved (fond vert + checkmark animé pendant 2 secondes).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 08:14:35 +01:00
5 changed files with 143 additions and 21 deletions

View File

@@ -107,6 +107,30 @@ export interface UpdateEvaluationInput {
}[]; }[];
} }
export async function updateDimensionScore(
evaluationId: string,
dimensionId: string,
data: { score?: number | null; justification?: string | null; examplesObserved?: string | null; confidence?: string | null; candidateNotes?: string | null }
): Promise<ActionResult> {
const session = await auth();
if (!session?.user) return { success: false, error: "Non authentifié" };
const hasAccess = await canAccessEvaluation(evaluationId, session.user.id, session.user.role === "admin");
if (!hasAccess) return { success: false, error: "Accès refusé" };
try {
await prisma.dimensionScore.upsert({
where: { evaluationId_dimensionId: { evaluationId, dimensionId } },
update: data,
create: { evaluationId, dimensionId, ...data },
});
revalidatePath(`/evaluations/${evaluationId}`);
return { success: true };
} catch (e) {
return { success: false, error: e instanceof Error ? e.message : "Erreur" };
}
}
export async function updateEvaluation(id: string, data: UpdateEvaluationInput): Promise<ActionResult> { export async function updateEvaluation(id: string, data: UpdateEvaluationInput): Promise<ActionResult> {
const session = await auth(); const session = await auth();
if (!session?.user) return { success: false, error: "Non authentifié" }; if (!session?.user) return { success: false, error: "Non authentifié" };

View File

@@ -25,3 +25,14 @@ input:focus, select:focus, textarea:focus {
outline: none; outline: none;
ring: 2px; ring: 2px;
} }
@keyframes check {
0% { stroke-dashoffset: 20; opacity: 0; }
50% { opacity: 1; }
100% { stroke-dashoffset: 0; opacity: 1; }
}
.check-icon polyline {
stroke-dasharray: 20;
animation: check 0.3s ease-out forwards;
}

View File

@@ -1,6 +1,10 @@
"use client"; "use client";
import { useState } from "react";
import { updateEvaluation } from "@/actions/evaluations";
interface CandidateFormProps { interface CandidateFormProps {
evaluationId?: string;
candidateName: string; candidateName: string;
candidateRole: string; candidateRole: string;
candidateTeam?: string; candidateTeam?: string;
@@ -18,7 +22,10 @@ const inputClass =
const labelClass = "mb-1 block text-xs font-medium text-zinc-500 dark:text-zinc-400"; const labelClass = "mb-1 block text-xs font-medium text-zinc-500 dark:text-zinc-400";
const savedStyle = { borderColor: "#a855f7", boxShadow: "0 0 0 1px #a855f733" };
export function CandidateForm({ export function CandidateForm({
evaluationId,
candidateName, candidateName,
candidateRole, candidateRole,
candidateTeam = "", candidateTeam = "",
@@ -30,6 +37,25 @@ export function CandidateForm({
disabled, disabled,
templateDisabled, templateDisabled,
}: CandidateFormProps) { }: CandidateFormProps) {
const [dirtyFields, setDirtyFields] = useState<Record<string, boolean>>({});
const [savedField, setSavedField] = useState<string | null>(null);
const markDirty = (field: string, value: string) => {
setDirtyFields((p) => ({ ...p, [field]: true }));
setSavedField(null);
onChange(field, value);
};
const saveOnBlur = (field: string, value: string) => {
if (!dirtyFields[field] || !evaluationId) return;
setDirtyFields((p) => ({ ...p, [field]: false }));
setSavedField(field);
updateEvaluation(evaluationId, { [field]: value || null } as Parameters<typeof updateEvaluation>[1]);
setTimeout(() => setSavedField((cur) => (cur === field ? null : cur)), 800);
};
const fieldStyle = (field: string) => (savedField === field ? savedStyle : undefined);
return ( return (
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
<div className="sm:col-span-2 lg:col-span-1"> <div className="sm:col-span-2 lg:col-span-1">
@@ -37,8 +63,10 @@ export function CandidateForm({
<input <input
type="text" type="text"
value={candidateName} value={candidateName}
onChange={(e) => onChange("candidateName", e.target.value)} onChange={(e) => markDirty("candidateName", e.target.value)}
className={inputClass} onBlur={(e) => saveOnBlur("candidateName", e.target.value)}
className={`${inputClass} transition-colors duration-200`}
style={fieldStyle("candidateName")}
disabled={disabled} disabled={disabled}
placeholder="Alice Chen" placeholder="Alice Chen"
/> />
@@ -48,8 +76,10 @@ export function CandidateForm({
<input <input
type="text" type="text"
value={candidateRole} value={candidateRole}
onChange={(e) => onChange("candidateRole", e.target.value)} onChange={(e) => markDirty("candidateRole", e.target.value)}
className={inputClass} onBlur={(e) => saveOnBlur("candidateRole", e.target.value)}
className={`${inputClass} transition-colors duration-200`}
style={fieldStyle("candidateRole")}
disabled={disabled} disabled={disabled}
placeholder="ML Engineer" placeholder="ML Engineer"
/> />
@@ -59,8 +89,10 @@ export function CandidateForm({
<input <input
type="text" type="text"
value={candidateTeam} value={candidateTeam}
onChange={(e) => onChange("candidateTeam", e.target.value)} onChange={(e) => markDirty("candidateTeam", e.target.value)}
className={inputClass} onBlur={(e) => saveOnBlur("candidateTeam", e.target.value)}
className={`${inputClass} transition-colors duration-200`}
style={fieldStyle("candidateTeam")}
disabled={disabled} disabled={disabled}
placeholder="Peaksys" placeholder="Peaksys"
/> />
@@ -71,8 +103,10 @@ export function CandidateForm({
<input <input
type="text" type="text"
value={evaluatorName} value={evaluatorName}
onChange={(e) => onChange("evaluatorName", e.target.value)} onChange={(e) => markDirty("evaluatorName", e.target.value)}
className={inputClass} onBlur={(e) => saveOnBlur("evaluatorName", e.target.value)}
className={`${inputClass} transition-colors duration-200`}
style={fieldStyle("evaluatorName")}
disabled={disabled} disabled={disabled}
placeholder="Jean D." placeholder="Jean D."
/> />
@@ -82,8 +116,10 @@ export function CandidateForm({
<input <input
type="date" type="date"
value={evaluationDate} value={evaluationDate}
onChange={(e) => onChange("evaluationDate", e.target.value)} onChange={(e) => markDirty("evaluationDate", e.target.value)}
className={inputClass} onBlur={(e) => saveOnBlur("evaluationDate", e.target.value)}
className={`${inputClass} transition-colors duration-200`}
style={fieldStyle("evaluationDate")}
disabled={disabled} disabled={disabled}
/> />
</div> </div>
@@ -91,8 +127,10 @@ export function CandidateForm({
<label className={labelClass}>Modèle</label> <label className={labelClass}>Modèle</label>
<select <select
value={templateId} value={templateId}
onChange={(e) => onChange("templateId", e.target.value)} onChange={(e) => markDirty("templateId", e.target.value)}
className={inputClass} onBlur={(e) => saveOnBlur("templateId", e.target.value)}
className={`${inputClass} transition-colors duration-200`}
style={fieldStyle("templateId")}
disabled={disabled || templateDisabled} disabled={disabled || templateDisabled}
> >
<option value=""></option> <option value=""></option>

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { updateDimensionScore } from "@/actions/evaluations";
const STORAGE_KEY_PREFIX = "eval-dim-expanded"; const STORAGE_KEY_PREFIX = "eval-dim-expanded";
@@ -80,6 +81,21 @@ const inputClass =
export function DimensionCard({ dimension, score, index, evaluationId, onScoreChange, collapseAllTrigger }: DimensionCardProps) { export function DimensionCard({ dimension, score, index, evaluationId, onScoreChange, collapseAllTrigger }: DimensionCardProps) {
const [notes, setNotes] = useState(score?.candidateNotes ?? ""); 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 hasQuestions = parseQuestions(dimension.suggestedQuestions).length > 0;
const [expanded, setExpanded] = useState(hasQuestions); const [expanded, setExpanded] = useState(hasQuestions);
@@ -191,10 +207,13 @@ export function DimensionCard({ dimension, score, index, evaluationId, onScoreCh
value={notes} value={notes}
onChange={(e) => { onChange={(e) => {
setNotes(e.target.value); setNotes(e.target.value);
markDirty("notes");
onScoreChange(dimension.id, { candidateNotes: e.target.value }); onScoreChange(dimension.id, { candidateNotes: e.target.value });
}} }}
onBlur={() => saveOnBlur("notes", { candidateNotes: notes })}
rows={2} rows={2}
className={inputClass} className={`${inputClass} transition-colors duration-200`}
style={savedField === "notes" ? savedStyle : undefined}
placeholder="Réponses du candidat..." placeholder="Réponses du candidat..."
/> />
</div> </div>
@@ -206,8 +225,13 @@ export function DimensionCard({ dimension, score, index, evaluationId, onScoreCh
<input <input
type="text" type="text"
value={score?.justification ?? ""} value={score?.justification ?? ""}
onChange={(e) => onScoreChange(dimension.id, { justification: e.target.value || null })} onChange={(e) => {
className={inputClass} 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..." placeholder="Courte..."
/> />
</div> </div>
@@ -216,8 +240,13 @@ export function DimensionCard({ dimension, score, index, evaluationId, onScoreCh
<input <input
type="text" type="text"
value={score?.examplesObserved ?? ""} value={score?.examplesObserved ?? ""}
onChange={(e) => onScoreChange(dimension.id, { examplesObserved: e.target.value || null })} onChange={(e) => {
className={inputClass} 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..." placeholder="Concrets..."
/> />
</div> </div>
@@ -225,8 +254,13 @@ export function DimensionCard({ dimension, score, index, evaluationId, onScoreCh
<label className="text-xs text-zinc-500">Confiance</label> <label className="text-xs text-zinc-500">Confiance</label>
<select <select
value={score?.confidence ?? ""} value={score?.confidence ?? ""}
onChange={(e) => onScoreChange(dimension.id, { confidence: e.target.value || null })} onChange={(e) => {
className={inputClass} 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=""></option>
<option value="low">Faible</option> <option value="low">Faible</option>

View File

@@ -59,6 +59,7 @@ export function EvaluationEditor({ id, initialEvaluation, templates, users }: Ev
const router = useRouter(); const router = useRouter();
const [evaluation, setEvaluation] = useState<Evaluation>(initialEvaluation); const [evaluation, setEvaluation] = useState<Evaluation>(initialEvaluation);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [exportOpen, setExportOpen] = useState(false); const [exportOpen, setExportOpen] = useState(false);
const [shareOpen, setShareOpen] = useState(false); const [shareOpen, setShareOpen] = useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
@@ -149,6 +150,8 @@ export function EvaluationEditor({ id, initialEvaluation, templates, users }: Ev
}); });
if (result.success) { if (result.success) {
if (!options?.skipRefresh) fetchEval(); if (!options?.skipRefresh) fetchEval();
setSaved(true);
setTimeout(() => setSaved(false), 2000);
} else { } else {
alert(result.error); alert(result.error);
} }
@@ -208,9 +211,20 @@ export function EvaluationEditor({ id, initialEvaluation, templates, users }: Ev
<button <button
onClick={() => handleSave()} onClick={() => handleSave()}
disabled={saving} disabled={saving}
className="rounded border border-zinc-300 dark:border-zinc-600 bg-zinc-100 dark:bg-zinc-700 px-3 py-1.5 font-mono text-xs text-zinc-700 dark:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-zinc-700 disabled:opacity-50" className={`rounded border px-3 py-1.5 font-mono text-xs disabled:opacity-50 transition-all duration-300 flex items-center gap-1.5 ${
saved
? "border-purple-500/50 bg-purple-500/10 text-purple-600 dark:text-purple-400"
: "border-zinc-300 dark:border-zinc-600 bg-zinc-100 dark:bg-zinc-700 text-zinc-700 dark:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-zinc-600"
}`}
> >
{saving ? "..." : "save"} {saving ? (
<span className="inline-block h-3 w-3 animate-spin rounded-full border border-zinc-400 border-t-transparent" />
) : saved ? (
<svg className="check-icon h-3 w-3" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="1.5,6 4.5,9 10.5,3" />
</svg>
) : null}
{saving ? "saving" : saved ? "saved" : "save"}
</button> </button>
<button <button
onClick={() => { onClick={() => {
@@ -253,6 +267,7 @@ export function EvaluationEditor({ id, initialEvaluation, templates, users }: Ev
Session Session
</h2> </h2>
<CandidateForm <CandidateForm
evaluationId={id}
candidateName={evaluation.candidateName} candidateName={evaluation.candidateName}
candidateRole={evaluation.candidateRole} candidateRole={evaluation.candidateRole}
candidateTeam={evaluation.candidateTeam ?? ""} candidateTeam={evaluation.candidateTeam ?? ""}