From cfde81b8dea6753c1a6703d671be72a58c1e9e8c Mon Sep 17 00:00:00 2001 From: Froidefond Julien Date: Wed, 25 Feb 2026 08:29:51 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20auto-save=20cibl=C3=A9=20au=20blur=20av?= =?UTF-8?q?ec=20feedback=20violet=20sur=20tous=20les=20champs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/actions/evaluations.ts | 24 +++++++++++ src/components/CandidateForm.tsx | 62 +++++++++++++++++++++++------ src/components/DimensionCard.tsx | 61 +++++++++++++++++++--------- src/components/EvaluationEditor.tsx | 4 +- 4 files changed, 118 insertions(+), 33 deletions(-) diff --git a/src/actions/evaluations.ts b/src/actions/evaluations.ts index 7274091..67dfac4 100644 --- a/src/actions/evaluations.ts +++ b/src/actions/evaluations.ts @@ -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 { + 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 { const session = await auth(); if (!session?.user) return { success: false, error: "Non authentifié" }; diff --git a/src/components/CandidateForm.tsx b/src/components/CandidateForm.tsx index 3b908f9..6ff6b67 100644 --- a/src/components/CandidateForm.tsx +++ b/src/components/CandidateForm.tsx @@ -1,6 +1,10 @@ "use client"; +import { useState } from "react"; +import { updateEvaluation } from "@/actions/evaluations"; + interface CandidateFormProps { + evaluationId?: string; candidateName: string; candidateRole: 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 savedStyle = { borderColor: "#a855f7", boxShadow: "0 0 0 1px #a855f733" }; + export function CandidateForm({ + evaluationId, candidateName, candidateRole, candidateTeam = "", @@ -30,6 +37,25 @@ export function CandidateForm({ disabled, templateDisabled, }: CandidateFormProps) { + const [dirtyFields, setDirtyFields] = useState>({}); + const [savedField, setSavedField] = useState(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[1]); + setTimeout(() => setSavedField((cur) => (cur === field ? null : cur)), 800); + }; + + const fieldStyle = (field: string) => (savedField === field ? savedStyle : undefined); + return (
@@ -37,8 +63,10 @@ export function CandidateForm({ onChange("candidateName", e.target.value)} - className={inputClass} + onChange={(e) => markDirty("candidateName", e.target.value)} + onBlur={(e) => saveOnBlur("candidateName", e.target.value)} + className={`${inputClass} transition-colors duration-200`} + style={fieldStyle("candidateName")} disabled={disabled} placeholder="Alice Chen" /> @@ -48,8 +76,10 @@ export function CandidateForm({ onChange("candidateRole", e.target.value)} - className={inputClass} + onChange={(e) => markDirty("candidateRole", e.target.value)} + onBlur={(e) => saveOnBlur("candidateRole", e.target.value)} + className={`${inputClass} transition-colors duration-200`} + style={fieldStyle("candidateRole")} disabled={disabled} placeholder="ML Engineer" /> @@ -59,8 +89,10 @@ export function CandidateForm({ onChange("candidateTeam", e.target.value)} - className={inputClass} + onChange={(e) => markDirty("candidateTeam", e.target.value)} + onBlur={(e) => saveOnBlur("candidateTeam", e.target.value)} + className={`${inputClass} transition-colors duration-200`} + style={fieldStyle("candidateTeam")} disabled={disabled} placeholder="Peaksys" /> @@ -71,8 +103,10 @@ export function CandidateForm({ onChange("evaluatorName", e.target.value)} - className={inputClass} + onChange={(e) => markDirty("evaluatorName", e.target.value)} + onBlur={(e) => saveOnBlur("evaluatorName", e.target.value)} + className={`${inputClass} transition-colors duration-200`} + style={fieldStyle("evaluatorName")} disabled={disabled} placeholder="Jean D." /> @@ -82,8 +116,10 @@ export function CandidateForm({ onChange("evaluationDate", e.target.value)} - className={inputClass} + onChange={(e) => markDirty("evaluationDate", e.target.value)} + onBlur={(e) => saveOnBlur("evaluationDate", e.target.value)} + className={`${inputClass} transition-colors duration-200`} + style={fieldStyle("evaluationDate")} disabled={disabled} />
@@ -91,8 +127,10 @@ export function CandidateForm({ onScoreChange(dimension.id, { justification: e.target.value || null })} - className={inputClass} + 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..." />
@@ -227,8 +240,13 @@ export function DimensionCard({ dimension, score, index, evaluationId, onScoreCh onScoreChange(dimension.id, { examplesObserved: e.target.value || null })} - className={inputClass} + 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..." /> @@ -236,8 +254,13 @@ export function DimensionCard({ dimension, score, index, evaluationId, onScoreCh