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:
190
src/components/DimensionCard.tsx
Normal file
190
src/components/DimensionCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user