Enhance project setup with Prisma, new scripts, and dependencies; update README for clarity and add API routes; improve layout and styling for better user experience

This commit is contained in:
Julien Froidefond
2026-02-20 09:12:37 +01:00
parent 4ecd13a93a
commit f0c5d768db
33 changed files with 4277 additions and 107 deletions

View File

@@ -0,0 +1,190 @@
"use client";
import { useState } from "react";
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;
onScoreChange: (dimensionId: string, data: Partial<DimensionScore>) => void;
}
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, onScoreChange }: DimensionCardProps) {
const [notes, setNotes] = useState(score?.candidateNotes ?? "");
const hasQuestions = parseQuestions(dimension.suggestedQuestions).length > 0;
const [expanded, setExpanded] = useState(hasQuestions);
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={() => setExpanded((e) => !e)}
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>
);
}