Compare commits

...

15 Commits

Author SHA1 Message Date
3e9b64694d chore: readable compose up
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m20s
2026-03-19 08:21:04 +01:00
d4bfcb93c7 chore: rename docker service from app to iag-dev-evaluator
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m49s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 08:11:06 +01:00
7662922a8b feat: add templates page with list and diff comparison views
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m46s
Server-first: page.tsx renders list and compare views as server components,
with <details>/<summary> accordions and URL-param navigation. Only the two
template selects require a client component (TemplateCompareSelects). Diff
highlighting uses a subtle cyan underline; layout is 2-col on desktop.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 08:38:51 +01:00
32e1f07418 feat: add template V2 with updated rubrics and fix ActionResult runtime error
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4m22s
- Add RUBRICS_V2 with improved rubrics for prompts, conception, iteration,
  evaluation, alignment and cost_control dimensions
- Add "Full - 15 dimensions (V2)" template using RUBRICS_V2; V1 unchanged
- Set V2 as default template by ordering templates by id desc in getTemplates
- Point demo seed evaluations to full-15-v2
- Remove `export type { ActionResult }` from "use server" files (evaluations,
  admin, share) — Turbopack treats all exports as server actions, causing a
  runtime ReferenceError when the type is erased at compile time

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 08:14:43 +01:00
88da5742ec feat: improve RadarChart responsiveness with client-side rendering
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m18s
- Introduced useEffect and useState to manage component mounting, ensuring the chart renders correctly on the client side.
- Updated the rendering logic to prevent SSR issues with ResponsiveContainer dimensions, enhancing layout stability.
2026-02-25 14:24:16 +01:00
17f5dfbf94 feat: enhance RadarChart component with initial dimensions
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 3s
- Added initial dimensions for height and width based on the compact prop to improve layout handling.
- Updated ResponsiveContainer to utilize the new initialDimension prop for better responsiveness.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 14:22:02 +01:00
e4a4e5a869 feat: integrate authentication session into Header component
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4m46s
- Updated RootLayout to fetch the authentication session and pass it to the Header component.
- Modified Header to accept session as a prop, enhancing user experience by displaying user-specific information and sign-out functionality.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 14:16:41 +01:00
2d8d59322d refactor: déduplication — helpers actions, parseurs partagés, types auth
- Crée src/lib/action-helpers.ts avec ActionResult, requireAuth(),
  requireEvaluationAccess() — type et pattern dupliqués 3× supprimés
- evaluations.ts, share.ts, admin.ts importent depuis action-helpers;
  admin.ts: "Forbidden" → "Accès refusé" pour cohérence
- parseQuestions/parseRubric exportées depuis export-utils et supprimées
  de DimensionCard (copie exacte retirée)
- next-auth.d.ts: Session.user.role passe de optional à required string

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 13:43:57 +01:00
ebd8573299 perf: suppression des $queryRaw redondants et cache sur getTemplates
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 3s
- getEvaluation/getTemplates: retire les $queryRaw qui dupliquaient les
  données déjà chargées via Prisma include (2 requêtes DB → 1)
- getTemplates: wrappé avec cache() React pour dédupliquer les appels
  dans le même render tree
- Supprime l'import Prisma devenu inutile

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 13:31:08 +01:00
27866091bf perf: optimisations DB — batch queries et index
- createEvaluation: remplace N create() par un createMany() (N→1 requête)
- updateEvaluation: regroupe les upserts en $transaction() parallèle
- Ajout d'index sur Evaluation.evaluatorId, Evaluation.templateId,
  EvaluationShare.userId et AuditLog.evaluationId

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 13:27:57 +01:00
99e1a06137 feat: ajout favicon et icônes cross-platform (web, iOS, PWA)
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m54s
- iconfull.png comme favicon browser, apple-touch-icon et icône PWA
- manifest.json pour support Android/PWA
- icon.svg clipboard cyan dans public/

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 09:54:31 +01:00
d9073c29bc fix: update middleware matcher to exclude additional image formats
- Enhanced the matcher configuration to exclude SVG, PNG, JPG, JPEG, GIF, WEBP, and ICO file types from middleware processing, improving asset handling.
2026-02-25 09:36:57 +01:00
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
27 changed files with 772 additions and 179 deletions

View File

@@ -20,4 +20,4 @@ jobs:
DB_VOLUME_PATH: ${{ variables.DB_VOLUME_PATH }} DB_VOLUME_PATH: ${{ variables.DB_VOLUME_PATH }}
run: | run: |
if [ -n "${DB_VOLUME_PATH}" ]; then mkdir -p "$DB_VOLUME_PATH"; fi if [ -n "${DB_VOLUME_PATH}" ]; then mkdir -p "$DB_VOLUME_PATH"; fi
docker compose up -d --build BUILDKIT_PROGRESS=plain docker compose up -d --build

View File

@@ -1,6 +1,6 @@
# Dev avec hot reload (source montée) # Dev avec hot reload (source montée)
services: services:
app: iag-dev-evaluator:
build: build:
context: . context: .
dockerfile: Dockerfile.dev dockerfile: Dockerfile.dev

View File

@@ -1,5 +1,5 @@
services: services:
app: iag-dev-evaluator:
build: . build: .
ports: ports:
- "3044:3000" - "3044:3000"

View File

@@ -0,0 +1,11 @@
-- CreateIndex
CREATE INDEX "AuditLog_evaluationId_idx" ON "AuditLog"("evaluationId");
-- CreateIndex
CREATE INDEX "Evaluation_evaluatorId_idx" ON "Evaluation"("evaluatorId");
-- CreateIndex
CREATE INDEX "Evaluation_templateId_idx" ON "Evaluation"("templateId");
-- CreateIndex
CREATE INDEX "EvaluationShare_userId_idx" ON "EvaluationShare"("userId");

View File

@@ -66,6 +66,9 @@ model Evaluation {
isPublic Boolean @default(false) // visible par tous (ex. démo) isPublic Boolean @default(false) // visible par tous (ex. démo)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@index([evaluatorId])
@@index([templateId])
} }
model EvaluationShare { model EvaluationShare {
@@ -77,6 +80,7 @@ model EvaluationShare {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@unique([evaluationId, userId]) @@unique([evaluationId, userId])
@@index([userId])
} }
model DimensionScore { model DimensionScore {
@@ -106,4 +110,6 @@ model AuditLog {
newValue String? newValue String?
userId String? userId String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@index([evaluationId])
} }

View File

@@ -120,6 +120,22 @@ const RUBRICS: Record<string, string> = {
"1:Pas de réflexion — aucune idée de comment contribuer au partage;2:Passif — ouvert à partager si sollicité;3:Contributeur ponctuel — partage ses pratiques de temps en temps;4:Multiplicateur — anime des retours d'expérience, documente ses outils;5:Levier d'équipe — impulse une dynamique de diffusion, produit des ressources réutilisables", "1:Pas de réflexion — aucune idée de comment contribuer au partage;2:Passif — ouvert à partager si sollicité;3:Contributeur ponctuel — partage ses pratiques de temps en temps;4:Multiplicateur — anime des retours d'expérience, documente ses outils;5:Levier d'équipe — impulse une dynamique de diffusion, produit des ressources réutilisables",
}; };
const RUBRICS_V2: Record<string, string> = {
...RUBRICS,
prompts:
"1:Vague — instructions floues ou incomplètes, l'IA doit deviner l'intention, résultats aléatoires;2:Clair — instructions compréhensibles avec une intention explicite, adapte le niveau de détail à la tâche;3:Précis — donne du contexte utile, précise les contraintes et le résultat attendu, ajuste selon les réponses;4:Méthodique — sait trouver et réutiliser des prompts efficaces, adapte sa formulation selon l'outil et la tâche;5:Maîtrise — spécification \"verrouillée\" : périmètre + définitions + hypothèses + priorités en cas de conflit + critères de sortie/acceptation, minimise l'interprétation et la variabilité des réponses",
conception:
"1:Code direct — pas de phase conception, passage direct au code;2:Conception informelle — réflexion mentale ou notes rapides, pas de formalisation;3:Conception assistée — IA pour esquisser des designs, SDD ou schémas;4:Mode plan structuré — IA utilisée pour explorer options, challenger, documenter les décisions;5:Conception maîtrisée — boucle conception-validation itérative, alternatives comparées et trade-offs explicites avant de coder",
iteration:
"1:One-shot — une seule tentative, pas de retry si le résultat est insuffisant;2:Quelques itérations — 2-3 essais manuels, reformulation si la première réponse échoue;3:Itératif — retry systématique avec reformulation ciblée, sait identifier ce qui ne va pas pour corriger le tir;4:Planifié — découpage en étapes avant de commencer, chaque étape traitée et validée avant la suivante;5:IA sparring partner — dialogue continu avec l'IA pour explorer, affiner, challenger les réponses",
evaluation:
"1:Acceptation — acceptation des sorties sans vérification significative;2:Relecture superficielle — lecture rapide, pas de critères explicites;3:Vérif fonctionnelle — tests manuels ou automatisés, vérification du comportement;4:Regard archi — évaluation de la maintenabilité, cohérence avec les patterns existants;5:Vigilance avancée — détection active des hallucinations et erreurs subtiles, vérification croisée avec d'autres sources, checklist personnelle de contrôle",
alignment:
"1:Hors standards — code généré souvent non conforme, rework systématique;2:Rework fréquent — modifications régulières nécessaires pour aligner le code aux standards;3:Globalement aligné — code généralement conforme, ajustements mineurs, NFR basiques (logs, erreurs) pris en compte;4:Proactif — rules ou instructions dédiées pour respecter standards, archi et NFR (perf, sécurité, observabilité);5:Intégré — NFR systématiquement couverts, garde-fous automatisés (rules, linters, templates), peu ou pas de rework",
cost_control:
"1:Inconscient — pas de visibilité sur les coûts, usage sans limite;2:Aware — conscience des coûts, consulte sa consommation de temps en temps;3:Attentif — choisit le modèle selon la tâche (léger pour le simple, puissant pour le complexe), limite le contexte inutile;4:Économe — optimise activement ses usages (taille du contexte, regroupement de requêtes, évite les générations inutiles);5:Exemplaire — pratiques de sobriété maîtrisées, sait arbitrer coût vs qualité, partage ses astuces d'optimisation",
};
// Réponses réalistes par dimension et score (justification + exemples observés) // Réponses réalistes par dimension et score (justification + exemples observés)
const DEMO_RESPONSES: Record< const DEMO_RESPONSES: Record<
string, string,
@@ -407,6 +423,83 @@ const TEMPLATES_DATA = [
}, },
], ],
}, },
{
id: "full-15-v2",
name: "Full - 15 dimensions (V2)",
dimensions: [
{
id: "tools",
title: "Maîtrise individuelle de l'outillage",
rubric: RUBRICS_V2.tools,
},
{ id: "prompts", title: "Clarté des prompts", rubric: RUBRICS_V2.prompts },
{
id: "conception",
title: "Conception & mode plan (SDD, design)",
rubric: RUBRICS_V2.conception,
},
{
id: "context",
title: "Gestion du contexte",
rubric: RUBRICS_V2.context,
},
{
id: "iteration",
title: "Capacité d'itération",
rubric: RUBRICS_V2.iteration,
},
{
id: "evaluation",
title: "Évaluation critique",
rubric: RUBRICS_V2.evaluation,
},
{
id: "exploration",
title: "Exploration & veille (workflows, astuces, pertinence)",
rubric: RUBRICS_V2.exploration,
},
{
id: "alignment",
title: "Alignement archi & standards",
rubric: RUBRICS_V2.alignment,
},
{
id: "quality_usage",
title: "Usage pour la qualité (tests, review)",
rubric: RUBRICS_V2.quality_usage,
},
{
id: "learning",
title: "Montée en compétence via IA",
rubric: RUBRICS_V2.learning,
},
{
id: "cost_control",
title: "Maîtrise des coûts",
rubric: RUBRICS_V2.cost_control,
},
{
id: "integration",
title: "[Optionnel] Intégration dans les pratiques d'équipe",
rubric: RUBRICS_V2.integration,
},
{
id: "impact",
title: "[Optionnel] Impact sur la delivery",
rubric: RUBRICS_V2.impact,
},
{
id: "accompagnement",
title: "[Optionnel] Accompagnement & besoins",
rubric: RUBRICS_V2.accompagnement,
},
{
id: "scaling",
title: "[Optionnel] Mise à l'échelle des compétences & outils",
rubric: RUBRICS_V2.scaling,
},
],
},
]; ];
async function main() { async function main() {
@@ -452,7 +545,7 @@ async function main() {
// Upsert répondants (candidates) par nom : create si absent, update si existant. Ne vide pas les évaluations. // Upsert répondants (candidates) par nom : create si absent, update si existant. Ne vide pas les évaluations.
const template = await prisma.template.findUnique({ const template = await prisma.template.findUnique({
where: { id: "full-15" }, where: { id: "full-15-v2" },
}); });
if (!template) throw new Error("Template not found"); if (!template) throw new Error("Template not found");

28
public/icon.svg Normal file
View File

@@ -0,0 +1,28 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 75 100">
<!-- Board -->
<rect x="10" y="22" width="55" height="70" rx="6" ry="6" fill="#ECFEFF" stroke="#06B6D4" stroke-width="2.5"/>
<!-- Clip base (shadow/depth) -->
<rect x="25" y="14" width="26" height="17" rx="5" ry="5" fill="#0E7490"/>
<!-- Clip foreground -->
<rect x="26" y="12" width="23" height="15" rx="4" ry="4" fill="#06B6D4"/>
<!-- Clip hole -->
<rect x="32" y="8" width="11" height="10" rx="3" ry="3" fill="#0E7490"/>
<rect x="34" y="10" width="7" height="6" rx="2" ry="2" fill="#CFFAFE"/>
<!-- Checklist row 1 -->
<rect x="18" y="44" width="11" height="11" rx="3" fill="#CFFAFE" stroke="#06B6D4" stroke-width="1.5"/>
<polyline points="21,50 24,53 28,47" fill="none" stroke="#0891B2" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="33" y="47" width="22" height="5" rx="2.5" fill="#A5F3FC"/>
<!-- Checklist row 2 -->
<rect x="18" y="60" width="11" height="11" rx="3" fill="#CFFAFE" stroke="#06B6D4" stroke-width="1.5"/>
<polyline points="21,66 24,69 28,63" fill="none" stroke="#0891B2" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
<rect x="33" y="63" width="17" height="5" rx="2.5" fill="#A5F3FC"/>
<!-- Checklist row 3 — unchecked -->
<rect x="18" y="76" width="11" height="11" rx="3" fill="#CFFAFE" stroke="#A5F3FC" stroke-width="1.5"/>
<rect x="33" y="79" width="13" height="5" rx="2.5" fill="#CFFAFE"/>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
public/iconfull.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

17
public/manifest.json Normal file
View File

@@ -0,0 +1,17 @@
{
"name": "Évaluateur Maturité IA Gen",
"short_name": "IAG Evaluator",
"description": "Outil d'évaluation de la maturité IA Gen par Peaksys",
"start_url": "/",
"display": "standalone",
"background_color": "#09090b",
"theme_color": "#06B6D4",
"icons": [
{
"src": "/iconfull.png",
"sizes": "any",
"type": "image/png",
"purpose": "any maskable"
}
]
}

View File

@@ -1,14 +1,13 @@
"use server"; "use server";
import { auth } from "@/auth";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { requireAuth, type ActionResult } from "@/lib/action-helpers";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
export type ActionResult<T = void> = { success: true; data?: T } | { success: false; error: string };
export async function setUserRole(userId: string, role: "admin" | "evaluator"): Promise<ActionResult> { export async function setUserRole(userId: string, role: "admin" | "evaluator"): Promise<ActionResult> {
const session = await auth(); const session = await requireAuth();
if (session?.user?.role !== "admin") return { success: false, error: "Forbidden" }; if (!session || session.user.role !== "admin") return { success: false, error: "Accès refusé" };
if (!role || !["admin", "evaluator"].includes(role)) { if (!role || !["admin", "evaluator"].includes(role)) {
return { success: false, error: "Rôle invalide (admin | evaluator)" }; return { success: false, error: "Rôle invalide (admin | evaluator)" };
@@ -25,8 +24,8 @@ export async function setUserRole(userId: string, role: "admin" | "evaluator"):
} }
export async function deleteUser(userId: string): Promise<ActionResult> { export async function deleteUser(userId: string): Promise<ActionResult> {
const session = await auth(); const session = await requireAuth();
if (session?.user?.role !== "admin") return { success: false, error: "Forbidden" }; if (!session || session.user.role !== "admin") return { success: false, error: "Accès refusé" };
if (userId === session.user.id) { if (userId === session.user.id) {
return { success: false, error: "Impossible de supprimer votre propre compte" }; return { success: false, error: "Impossible de supprimer votre propre compte" };

View File

@@ -1,16 +1,14 @@
"use server"; "use server";
import { auth } from "@/auth";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { canAccessEvaluation } from "@/lib/evaluation-access";
import { getEvaluation } from "@/lib/server-data"; import { getEvaluation } from "@/lib/server-data";
import { requireAuth, requireEvaluationAccess, type ActionResult } from "@/lib/action-helpers";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
export type ActionResult<T = void> = { success: true; data?: T } | { success: false; error: string };
export async function fetchEvaluation(id: string): Promise<ActionResult<Awaited<ReturnType<typeof getEvaluation>>>> { export async function fetchEvaluation(id: string): Promise<ActionResult<Awaited<ReturnType<typeof getEvaluation>>>> {
const session = await auth(); const session = await requireAuth();
if (!session?.user) return { success: false, error: "Non authentifié" }; if (!session) return { success: false, error: "Non authentifié" };
const evaluation = await getEvaluation(id); const evaluation = await getEvaluation(id);
if (!evaluation) return { success: false, error: "Évaluation introuvable" }; if (!evaluation) return { success: false, error: "Évaluation introuvable" };
@@ -19,10 +17,10 @@ export async function fetchEvaluation(id: string): Promise<ActionResult<Awaited<
} }
export async function deleteEvaluation(id: string): Promise<ActionResult> { export async function deleteEvaluation(id: string): Promise<ActionResult> {
const session = await auth(); const session = await requireAuth();
if (!session?.user) return { success: false, error: "Non authentifié" }; if (!session) return { success: false, error: "Non authentifié" };
const hasAccess = await canAccessEvaluation(id, session.user.id, session.user.role === "admin"); const hasAccess = await requireEvaluationAccess(id, session.user.id, session.user.role === "admin");
if (!hasAccess) return { success: false, error: "Accès refusé" }; if (!hasAccess) return { success: false, error: "Accès refusé" };
try { try {
@@ -42,8 +40,8 @@ export async function createEvaluation(data: {
evaluationDate: string; evaluationDate: string;
templateId: string; templateId: string;
}): Promise<ActionResult<{ id: string }>> { }): Promise<ActionResult<{ id: string }>> {
const session = await auth(); const session = await requireAuth();
if (!session?.user) return { success: false, error: "Non authentifié" }; if (!session) return { success: false, error: "Non authentifié" };
const { candidateName, candidateRole, candidateTeam, evaluationDate, templateId } = data; const { candidateName, candidateRole, candidateTeam, evaluationDate, templateId } = data;
if (!candidateName || !candidateRole || !evaluationDate || !templateId) { if (!candidateName || !candidateRole || !evaluationDate || !templateId) {
@@ -72,11 +70,12 @@ export async function createEvaluation(data: {
}, },
}); });
for (const dim of template.dimensions) { await prisma.dimensionScore.createMany({
await prisma.dimensionScore.create({ data: template.dimensions.map((dim) => ({
data: { evaluationId: evaluation.id, dimensionId: dim.id }, evaluationId: evaluation.id,
}); dimensionId: dim.id,
} })),
});
revalidatePath("/dashboard"); revalidatePath("/dashboard");
return { success: true, data: { id: evaluation.id } }; return { success: true, data: { id: evaluation.id } };
@@ -107,11 +106,35 @@ export interface UpdateEvaluationInput {
}[]; }[];
} }
export async function updateEvaluation(id: string, data: UpdateEvaluationInput): Promise<ActionResult> { export async function updateDimensionScore(
const session = await auth(); evaluationId: string,
if (!session?.user) return { success: false, error: "Non authentifié" }; dimensionId: string,
data: { score?: number | null; justification?: string | null; examplesObserved?: string | null; confidence?: string | null; candidateNotes?: string | null }
): Promise<ActionResult> {
const session = await requireAuth();
if (!session) return { success: false, error: "Non authentifié" };
const hasAccess = await canAccessEvaluation(id, session.user.id, session.user.role === "admin"); const hasAccess = await requireEvaluationAccess(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> {
const session = await requireAuth();
if (!session) return { success: false, error: "Non authentifié" };
const hasAccess = await requireEvaluationAccess(id, session.user.id, session.user.role === "admin");
if (!hasAccess) return { success: false, error: "Accès refusé" }; if (!hasAccess) return { success: false, error: "Accès refusé" };
const existing = await prisma.evaluation.findUnique({ where: { id } }); const existing = await prisma.evaluation.findUnique({ where: { id } });
@@ -153,30 +176,33 @@ export async function updateEvaluation(id: string, data: UpdateEvaluationInput):
} }
if (dimensionScores && Array.isArray(dimensionScores)) { if (dimensionScores && Array.isArray(dimensionScores)) {
for (const ds of dimensionScores) { const validScores = dimensionScores.filter((ds) => ds.dimensionId);
if (ds.dimensionId) { if (validScores.length > 0) {
await prisma.dimensionScore.upsert({ await prisma.$transaction(
where: { validScores.map((ds) =>
evaluationId_dimensionId: { evaluationId: id, dimensionId: ds.dimensionId }, prisma.dimensionScore.upsert({
}, where: {
update: { evaluationId_dimensionId: { evaluationId: id, dimensionId: ds.dimensionId },
score: ds.score, },
justification: ds.justification, update: {
examplesObserved: ds.examplesObserved, score: ds.score,
confidence: ds.confidence, justification: ds.justification,
candidateNotes: ds.candidateNotes, examplesObserved: ds.examplesObserved,
}, confidence: ds.confidence,
create: { candidateNotes: ds.candidateNotes,
evaluationId: id, },
dimensionId: ds.dimensionId, create: {
score: ds.score, evaluationId: id,
justification: ds.justification, dimensionId: ds.dimensionId,
examplesObserved: ds.examplesObserved, score: ds.score,
confidence: ds.confidence, justification: ds.justification,
candidateNotes: ds.candidateNotes, examplesObserved: ds.examplesObserved,
}, confidence: ds.confidence,
}); candidateNotes: ds.candidateNotes,
} },
})
)
);
} }
} }

View File

@@ -1,21 +1,15 @@
"use server"; "use server";
import { auth } from "@/auth";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { canAccessEvaluation } from "@/lib/evaluation-access"; import { requireAuth, requireEvaluationAccess, type ActionResult } from "@/lib/action-helpers";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
export type ActionResult<T = void> = { success: true; data?: T } | { success: false; error: string };
export async function addShare(evaluationId: string, userId: string): Promise<ActionResult> { export async function addShare(evaluationId: string, userId: string): Promise<ActionResult> {
const session = await auth(); const session = await requireAuth();
if (!session?.user) return { success: false, error: "Non authentifié" }; if (!session) return { success: false, error: "Non authentifié" };
const hasAccess = await canAccessEvaluation( const hasAccess = await requireEvaluationAccess(evaluationId, session.user.id, session.user.role === "admin");
evaluationId,
session.user.id,
session.user.role === "admin"
);
if (!hasAccess) return { success: false, error: "Accès refusé" }; if (!hasAccess) return { success: false, error: "Accès refusé" };
if (userId === session.user.id) return { success: false, error: "Vous avez déjà accès" }; if (userId === session.user.id) return { success: false, error: "Vous avez déjà accès" };
@@ -43,14 +37,10 @@ export async function addShare(evaluationId: string, userId: string): Promise<Ac
} }
export async function removeShare(evaluationId: string, userId: string): Promise<ActionResult> { export async function removeShare(evaluationId: string, userId: string): Promise<ActionResult> {
const session = await auth(); const session = await requireAuth();
if (!session?.user) return { success: false, error: "Non authentifié" }; if (!session) return { success: false, error: "Non authentifié" };
const hasAccess = await canAccessEvaluation( const hasAccess = await requireEvaluationAccess(evaluationId, session.user.id, session.user.role === "admin");
evaluationId,
session.user.id,
session.user.role === "admin"
);
if (!hasAccess) return { success: false, error: "Accès refusé" }; if (!hasAccess) return { success: false, error: "Accès refusé" };
try { try {

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,5 +1,6 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Geist, Geist_Mono } from "next/font/google";
import { auth } from "@/auth";
import { Header } from "@/components/Header"; import { Header } from "@/components/Header";
import { ThemeProvider } from "@/components/ThemeProvider"; import { ThemeProvider } from "@/components/ThemeProvider";
import { SessionProvider } from "@/components/SessionProvider"; import { SessionProvider } from "@/components/SessionProvider";
@@ -18,19 +19,26 @@ const geistMono = Geist_Mono({
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Évaluateur Maturité IA Gen", title: "Évaluateur Maturité IA Gen",
description: "Outil d'évaluation de la maturité IA Gen par Peaksys", description: "Outil d'évaluation de la maturité IA Gen par Peaksys",
icons: {
icon: "/iconfull.png",
shortcut: "/iconfull.png",
apple: "/iconfull.png",
},
manifest: "/manifest.json",
}; };
export default function RootLayout({ export default async function RootLayout({
children, children,
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
const session = await auth();
return ( return (
<html lang="fr" suppressHydrationWarning> <html lang="fr" suppressHydrationWarning>
<body className={`${geistSans.variable} ${geistMono.variable} min-h-screen bg-zinc-100 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-50 antialiased`}> <body className={`${geistSans.variable} ${geistMono.variable} min-h-screen bg-zinc-100 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-50 antialiased`}>
<SessionProvider> <SessionProvider>
<ThemeProvider> <ThemeProvider>
<Header /> <Header session={session} />
<main className="mx-auto max-w-7xl px-4 py-6">{children}</main> <main className="mx-auto max-w-7xl px-4 py-6">{children}</main>
</ThemeProvider> </ThemeProvider>
</SessionProvider> </SessionProvider>

283
src/app/templates/page.tsx Normal file
View File

@@ -0,0 +1,283 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { getTemplates } from "@/lib/server-data";
import { parseRubric, parseQuestions } from "@/lib/export-utils";
import { TemplateCompareSelects } from "@/components/TemplateCompareSelects";
interface PageProps {
searchParams: Promise<{ mode?: string; left?: string; right?: string; diffs?: string }>;
}
// ── Types ────────────────────────────────────────────────────────────────────
type TemplateDimension = Awaited<ReturnType<typeof getTemplates>>[number]["dimensions"][number];
type Template = Awaited<ReturnType<typeof getTemplates>>[number];
// ── Sub-components (server) ───────────────────────────────────────────────────
function DimensionAccordion({ dim, index }: { dim: TemplateDimension; index: number }) {
const rubricLabels = parseRubric(dim.rubric);
const questions = parseQuestions(dim.suggestedQuestions);
return (
<details className="group border-t border-zinc-200 dark:border-zinc-600 first:border-t-0">
<summary className="flex cursor-pointer list-none items-center justify-between px-4 py-2.5 hover:bg-zinc-50 dark:hover:bg-zinc-700/50 transition-colors [&::-webkit-details-marker]:hidden">
<div className="flex items-center gap-2 min-w-0">
<span className="font-mono text-xs text-zinc-400 tabular-nums w-5 shrink-0">{index + 1}.</span>
<span className="text-sm font-medium text-zinc-800 dark:text-zinc-100 truncate">{dim.title}</span>
</div>
<span className="shrink-0 text-zinc-500 text-sm ml-2 group-open:hidden">+</span>
<span className="shrink-0 text-zinc-500 text-sm ml-2 hidden group-open:inline"></span>
</summary>
<div className="px-4 pb-3 space-y-2 bg-zinc-50/50 dark:bg-zinc-700/20">
{questions.length > 0 && (
<div className="rounded bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-600 p-2.5">
<p className="mb-1.5 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>
)}
<div className="rounded bg-white dark:bg-zinc-800 border border-zinc-200 dark:border-zinc-600 p-2.5 font-mono text-xs space-y-0.5">
{rubricLabels.map((label, i) => (
<div key={i} className="text-zinc-600 dark:text-zinc-300">
<span className="text-cyan-600 dark:text-cyan-400">{i + 1}</span> {label}
</div>
))}
</div>
</div>
</details>
);
}
function ListView({ templates }: { templates: Template[] }) {
if (templates.length === 0) {
return (
<div className="py-12 text-center font-mono text-sm text-zinc-500">
Aucun template disponible.
</div>
);
}
return (
<div className="grid gap-4 grid-cols-1 md:grid-cols-2">
{templates.map((t) => (
<div
key={t.id}
className="rounded-lg border border-zinc-200 dark:border-zinc-600 bg-white dark:bg-zinc-800 shadow-sm dark:shadow-none overflow-hidden"
>
<div className="px-4 py-3 flex items-center justify-between border-b border-zinc-200 dark:border-zinc-600 bg-zinc-50 dark:bg-zinc-700/30">
<h2 className="font-medium text-zinc-800 dark:text-zinc-100">{t.name}</h2>
<span className="font-mono text-xs text-zinc-500">{t.dimensions.length} dim.</span>
</div>
<div>
{t.dimensions.map((dim, i) => (
<DimensionAccordion key={dim.id} dim={dim} index={i} />
))}
</div>
</div>
))}
</div>
);
}
function CompareView({
templates,
leftId,
rightId,
onlyDiffs,
}: {
templates: Template[];
leftId: string;
rightId: string;
onlyDiffs: boolean;
}) {
const leftTemplate = templates.find((t) => t.id === leftId);
const rightTemplate = templates.find((t) => t.id === rightId);
// Collect all unique slugs, preserving order
const slugOrder = new Map<string, number>();
for (const t of [leftTemplate, rightTemplate]) {
if (!t) continue;
for (const dim of t.dimensions) {
if (!slugOrder.has(dim.slug)) slugOrder.set(dim.slug, dim.orderIndex);
}
}
const slugs = Array.from(slugOrder.keys()).sort(
(a, b) => (slugOrder.get(a) ?? 0) - (slugOrder.get(b) ?? 0)
);
// Pre-compute diffs
const diffsBySlugs = new Map<string, number[]>();
let totalDiffDims = 0;
for (const slug of slugs) {
const l = leftTemplate?.dimensions.find((d) => d.slug === slug);
const r = rightTemplate?.dimensions.find((d) => d.slug === slug);
const ll = l ? parseRubric(l.rubric) : [];
const rl = r ? parseRubric(r.rubric) : [];
const diff = [0, 1, 2, 3, 4].filter((i) => ll[i] !== rl[i]);
diffsBySlugs.set(slug, diff);
if (diff.length > 0) totalDiffDims++;
}
const visibleSlugs = onlyDiffs ? slugs.filter((s) => (diffsBySlugs.get(s)?.length ?? 0) > 0) : slugs;
const base = `?mode=compare&left=${leftId}&right=${rightId}`;
const diffsHref = onlyDiffs ? base : `${base}&diffs=1`;
return (
<div>
{templates.length > 2 && (
<TemplateCompareSelects
templates={templates.map((t) => ({ id: t.id, name: t.name }))}
leftId={leftId}
rightId={rightId}
onlyDiffs={onlyDiffs}
/>
)}
{/* Summary + filter */}
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<p className="font-mono text-xs text-zinc-500">
<span className="font-semibold text-amber-600 dark:text-amber-400">{totalDiffDims}</span>
{" / "}
{slugs.length} dimensions modifiées
</p>
<Link
href={diffsHref}
className={`rounded border px-2.5 py-1 font-mono text-xs transition-colors ${
onlyDiffs
? "border-amber-400 bg-amber-50 dark:bg-amber-900/20 text-amber-600 dark:text-amber-400"
: "border-zinc-300 dark:border-zinc-600 text-zinc-500 dark:text-zinc-400 hover:border-zinc-400"
}`}
>
uniquement les différences
</Link>
</div>
{/* Column headers */}
<div className="grid grid-cols-2 gap-px mb-1">
<div className="rounded-t-lg bg-zinc-100 dark:bg-zinc-700/60 px-4 py-2 font-mono text-xs font-semibold text-zinc-600 dark:text-zinc-300">
{leftTemplate?.name ?? "—"}
</div>
<div className="rounded-t-lg bg-zinc-100 dark:bg-zinc-700/60 px-4 py-2 font-mono text-xs font-semibold text-zinc-600 dark:text-zinc-300">
{rightTemplate?.name ?? "—"}
</div>
</div>
<div className="space-y-2">
{visibleSlugs.map((slug, idx) => {
const leftDim = leftTemplate?.dimensions.find((d) => d.slug === slug);
const rightDim = rightTemplate?.dimensions.find((d) => d.slug === slug);
const leftLabels = leftDim ? parseRubric(leftDim.rubric) : [];
const rightLabels = rightDim ? parseRubric(rightDim.rubric) : [];
const title = (leftDim ?? rightDim)?.title ?? slug;
const diffLevels = diffsBySlugs.get(slug) ?? [];
const hasDiff = diffLevels.length > 0;
return (
<div
key={slug}
className="rounded-lg border border-zinc-200 dark:border-zinc-600 overflow-hidden"
>
<div className="px-4 py-2 border-b border-zinc-200 dark:border-zinc-600 bg-zinc-50 dark:bg-zinc-700/50 flex items-center justify-between gap-2">
<span className="font-medium text-sm text-zinc-800 dark:text-zinc-100">
<span className="font-mono text-xs text-zinc-400 mr-1.5 tabular-nums">{idx + 1}.</span>
{title}
</span>
{hasDiff && (
<span className="shrink-0 font-mono text-xs px-1.5 py-0.5 rounded bg-zinc-200 dark:bg-zinc-600 text-zinc-500 dark:text-zinc-400">
{diffLevels.length} Δ
</span>
)}
</div>
<div className="grid grid-cols-2 divide-x divide-zinc-200 dark:divide-zinc-600">
{[
{ dim: leftDim, labels: leftLabels },
{ dim: rightDim, labels: rightLabels },
].map(({ dim, labels }, col) => (
<div key={col} className="p-3 font-mono text-xs space-y-1">
{dim ? (
labels.map((label, i) => {
const differs = diffLevels.includes(i);
return (
<div key={i} className="flex gap-2 px-1.5 py-1">
<span className="shrink-0 font-bold tabular-nums text-cyan-600 dark:text-cyan-400">
{i + 1}
</span>
<span className={`text-zinc-600 dark:text-zinc-300 ${differs ? "bg-cyan-100/70 dark:bg-cyan-900/30 rounded px-0.5" : ""}`}>
{label}
</span>
</div>
);
})
) : (
<span className="text-zinc-400 italic">absent</span>
)}
</div>
))}
</div>
</div>
);
})}
</div>
</div>
);
}
// ── Page ─────────────────────────────────────────────────────────────────────
export default async function TemplatesPage({ searchParams }: PageProps) {
const session = await auth();
if (!session?.user) redirect("/auth/login");
const { mode, left, right, diffs } = await searchParams;
const templates = await getTemplates();
const isCompare = mode === "compare";
const leftId = left ?? templates[0]?.id ?? "";
const rightId = right ?? templates[1]?.id ?? templates[0]?.id ?? "";
const onlyDiffs = diffs === "1";
const compareHref = `?mode=compare&left=${leftId}&right=${rightId}`;
return (
<div>
<div className="mb-6 flex flex-wrap items-center justify-between gap-3">
<h1 className="font-mono text-lg font-medium text-zinc-800 dark:text-zinc-100">Templates</h1>
<div className="inline-flex rounded border border-zinc-200 dark:border-zinc-600 bg-zinc-50 dark:bg-zinc-800/50 p-0.5">
<Link
href="?mode=list"
className={`rounded px-2.5 py-1 font-mono text-xs transition-colors ${
!isCompare
? "bg-white dark:bg-zinc-700 text-zinc-800 dark:text-zinc-100 shadow-sm"
: "text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-300"
}`}
>
liste
</Link>
<Link
href={compareHref}
className={`rounded px-2.5 py-1 font-mono text-xs transition-colors ${
isCompare
? "bg-white dark:bg-zinc-700 text-zinc-800 dark:text-zinc-100 shadow-sm"
: "text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-300"
}`}
>
comparer
</Link>
</div>
</div>
{isCompare ? (
<CompareView templates={templates} leftId={leftId} rightId={rightId} onlyDiffs={onlyDiffs} />
) : (
<ListView templates={templates} />
)}
</div>
);
}

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,8 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { updateDimensionScore } from "@/actions/evaluations";
import { parseQuestions, parseRubric } from "@/lib/export-utils";
const STORAGE_KEY_PREFIX = "eval-dim-expanded"; const STORAGE_KEY_PREFIX = "eval-dim-expanded";
@@ -55,31 +57,27 @@ interface DimensionCardProps {
collapseAllTrigger?: number; 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 = 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"; "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) { 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 +189,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 +207,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 +222,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 +236,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 ?? ""}

View File

@@ -1,12 +1,9 @@
"use client";
import Link from "next/link"; import Link from "next/link";
import { signOut, useSession } from "next-auth/react"; import type { Session } from "next-auth";
import { ThemeToggle } from "./ThemeToggle"; import { ThemeToggle } from "./ThemeToggle";
import { SignOutButton } from "./SignOutButton";
export function Header() { export function Header({ session }: { session: Session | null }) {
const { data: session, status } = useSession();
return ( return (
<header className="sticky top-0 z-50 border-b border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-900 shadow-sm dark:shadow-none backdrop-blur-sm"> <header className="sticky top-0 z-50 border-b border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-900 shadow-sm dark:shadow-none backdrop-blur-sm">
<div className="mx-auto flex h-12 max-w-6xl items-center justify-between px-4"> <div className="mx-auto flex h-12 max-w-6xl items-center justify-between px-4">
@@ -17,7 +14,7 @@ export function Header() {
iag-eval iag-eval
</Link> </Link>
<nav className="flex items-center gap-6 font-mono text-xs"> <nav className="flex items-center gap-6 font-mono text-xs">
{status === "authenticated" ? ( {session ? (
<> <>
<Link <Link
href="/dashboard" href="/dashboard"
@@ -31,7 +28,13 @@ export function Header() {
> >
/new /new
</Link> </Link>
{session?.user?.role === "admin" && ( <Link
href="/templates"
className="text-zinc-500 hover:text-cyan-600 dark:text-zinc-400 dark:hover:text-cyan-400 transition-colors"
>
/templates
</Link>
{session.user.role === "admin" && (
<Link <Link
href="/admin" href="/admin"
className="text-zinc-500 hover:text-cyan-600 dark:text-zinc-400 dark:hover:text-cyan-400 transition-colors" className="text-zinc-500 hover:text-cyan-600 dark:text-zinc-400 dark:hover:text-cyan-400 transition-colors"
@@ -46,18 +49,9 @@ export function Header() {
/paramètres /paramètres
</Link> </Link>
<span className="text-zinc-400 dark:text-zinc-500"> <span className="text-zinc-400 dark:text-zinc-500">
{session?.user?.email} {session.user.email}
</span> </span>
<button <SignOutButton />
type="button"
onClick={async () => {
await signOut({ redirect: false });
window.location.href = "/auth/login";
}}
className="text-zinc-500 hover:text-red-500 dark:text-zinc-400 dark:hover:text-red-400 transition-colors"
>
déconnexion
</button>
</> </>
) : ( ) : (
<Link <Link

View File

@@ -1,5 +1,6 @@
"use client"; "use client";
import { useEffect, useState } from "react";
import { import {
Radar, Radar,
RadarChart as RechartsRadar, RadarChart as RechartsRadar,
@@ -43,10 +44,21 @@ const DARK = {
export function RadarChart({ data, compact }: RadarChartProps) { export function RadarChart({ data, compact }: RadarChartProps) {
const { theme } = useTheme(); const { theme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => {
// Defer chart until client so ResponsiveContainer can measure parent (avoids width/height -1 on SSR)
const raf = requestAnimationFrame(() => setMounted(true));
return () => cancelAnimationFrame(raf);
}, []);
const c = theme === "dark" ? DARK : LIGHT; const c = theme === "dark" ? DARK : LIGHT;
if (data.length === 0) return null; if (data.length === 0) return null;
// ResponsiveContainer needs real DOM dimensions; avoid -1 on SSR
if (!mounted) {
return <div className={compact ? "h-28 w-full" : "h-72 w-full"} aria-hidden />;
}
return ( return (
<div className={compact ? "h-28 w-full" : "h-72 w-full"}> <div className={compact ? "h-28 w-full" : "h-72 w-full"}>
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">

View File

@@ -0,0 +1,18 @@
"use client";
import { signOut } from "next-auth/react";
export function SignOutButton() {
return (
<button
type="button"
onClick={async () => {
await signOut({ redirect: false });
window.location.href = "/auth/login";
}}
className="text-zinc-500 hover:text-red-500 dark:text-zinc-400 dark:hover:text-red-400 transition-colors"
>
déconnexion
</button>
);
}

View File

@@ -0,0 +1,52 @@
"use client";
import { useRouter } from "next/navigation";
interface TemplateOption {
id: string;
name: string;
}
const selectClass =
"rounded border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-800 px-2 py-1 font-mono text-xs text-zinc-800 dark:text-zinc-100 focus:border-cyan-500 focus:outline-none";
export function TemplateCompareSelects({
templates,
leftId,
rightId,
onlyDiffs,
}: {
templates: TemplateOption[];
leftId: string;
rightId: string;
onlyDiffs: boolean;
}) {
const router = useRouter();
const push = (left: string, right: string) => {
const params = new URLSearchParams({ mode: "compare", left, right });
if (onlyDiffs) params.set("diffs", "1");
router.push(`/templates?${params.toString()}`);
};
return (
<div className="mb-4 flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<span className="font-mono text-xs text-zinc-500">Gauche :</span>
<select value={leftId} onChange={(e) => push(e.target.value, rightId)} className={selectClass}>
{templates.map((t) => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</select>
</div>
<div className="flex items-center gap-2">
<span className="font-mono text-xs text-zinc-500">Droite :</span>
<select value={rightId} onChange={(e) => push(leftId, e.target.value)} className={selectClass}>
{templates.map((t) => (
<option key={t.id} value={t.id}>{t.name}</option>
))}
</select>
</div>
</div>
);
}

15
src/lib/action-helpers.ts Normal file
View File

@@ -0,0 +1,15 @@
import { auth } from "@/auth";
import { canAccessEvaluation } from "@/lib/evaluation-access";
export type ActionResult<T = void> = { success: true; data?: T } | { success: false; error: string };
export async function requireAuth() {
const session = await auth();
if (!session?.user) return null;
return session;
}
export async function requireEvaluationAccess(evaluationId: string, userId: string, isAdmin: boolean) {
const hasAccess = await canAccessEvaluation(evaluationId, userId, isAdmin);
return hasAccess;
}

View File

@@ -6,7 +6,7 @@ export interface EvaluationWithScores extends Evaluation {
} }
/** Parse suggestedQuestions JSON array */ /** Parse suggestedQuestions JSON array */
function parseQuestions(s: string | null | undefined): string[] { export function parseQuestions(s: string | null | undefined): string[] {
if (!s) return []; if (!s) return [];
try { try {
const arr = JSON.parse(s) as unknown; const arr = JSON.parse(s) as unknown;
@@ -17,7 +17,7 @@ function parseQuestions(s: string | null | undefined): string[] {
} }
/** Parse rubric "1:X;2:Y;..." into labels */ /** Parse rubric "1:X;2:Y;..." into labels */
function parseRubric(rubric: string): string[] { export function parseRubric(rubric: string): string[] {
if (rubric === "1-5" || !rubric) return ["1", "2", "3", "4", "5"]; if (rubric === "1-5" || !rubric) return ["1", "2", "3", "4", "5"];
const labels: string[] = []; const labels: string[] = [];
for (let i = 1; i <= 5; i++) { for (let i = 1; i <= 5; i++) {

View File

@@ -1,4 +1,4 @@
import { Prisma } from "@prisma/client"; import { cache } from "react";
import { auth } from "@/auth"; import { auth } from "@/auth";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { canAccessEvaluation } from "@/lib/evaluation-access"; import { canAccessEvaluation } from "@/lib/evaluation-access";
@@ -62,60 +62,21 @@ export async function getEvaluation(id: string) {
); );
if (!hasAccess) return null; if (!hasAccess) return null;
const templateId = evaluation.templateId;
const dimsRaw = evaluation.template
? ((await prisma.$queryRaw(
Prisma.sql`SELECT id, slug, title, rubric, "orderIndex", "suggestedQuestions" FROM "TemplateDimension" WHERE "templateId" = ${templateId} ORDER BY "orderIndex" ASC`
)) as { id: string; slug: string; title: string; rubric: string; orderIndex: number; suggestedQuestions: string | null }[])
: [];
const dimMap = new Map(dimsRaw.map((d) => [d.id, d]));
return { return {
...evaluation, ...evaluation,
evaluationDate: evaluation.evaluationDate.toISOString(), evaluationDate: evaluation.evaluationDate.toISOString(),
template: evaluation.template
? {
...evaluation.template,
dimensions: evaluation.template.dimensions.map((d) => {
const raw = dimMap.get(d.id);
return {
...d,
suggestedQuestions: raw?.suggestedQuestions ?? d.suggestedQuestions,
};
}),
}
: null,
dimensionScores: evaluation.dimensionScores.map((ds) => ({
...ds,
dimension: ds.dimension
? {
...ds.dimension,
suggestedQuestions: dimMap.get(ds.dimension.id)?.suggestedQuestions ?? ds.dimension.suggestedQuestions,
}
: null,
})),
}; };
} }
export async function getTemplates() { export const getTemplates = cache(async () => {
const templates = await prisma.template.findMany({ const templates = await prisma.template.findMany({
include: { include: {
dimensions: { orderBy: { orderIndex: "asc" } }, dimensions: { orderBy: { orderIndex: "asc" } },
}, },
orderBy: { id: "desc" },
}); });
const dimsRaw = (await prisma.$queryRaw( return templates;
Prisma.sql`SELECT id, "templateId", slug, title, rubric, "orderIndex", "suggestedQuestions" FROM "TemplateDimension" ORDER BY "templateId", "orderIndex"` });
)) as { id: string; templateId: string; slug: string; title: string; rubric: string; orderIndex: number; suggestedQuestions: string | null }[];
const dimMap = new Map(dimsRaw.map((d) => [d.id, d]));
return templates.map((t) => ({
...t,
dimensions: t.dimensions.map((d) => ({
...d,
suggestedQuestions: dimMap.get(d.id)?.suggestedQuestions ?? d.suggestedQuestions,
})),
}));
}
export async function getUsers() { export async function getUsers() {
const session = await auth(); const session = await auth();

View File

@@ -25,5 +25,5 @@ export default auth((req) => {
}); });
export const config = { export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"], matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico)$).*)"],
}; };

View File

@@ -10,7 +10,7 @@ declare module "next-auth" {
id: string; id: string;
email?: string | null; email?: string | null;
name?: string | null; name?: string | null;
role?: string; role: string;
}; };
} }
} }