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

148
README.md
View File

@@ -1,36 +1,142 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
# IA Gen Maturity Evaluator
## Getting Started
Production-ready web app for evaluating IA/GenAI maturity of candidates. Built for the Cars Front team.
First, run the development server:
## Tech Stack
- **Next.js 16** (App Router), **React 19**, **TypeScript**, **TailwindCSS**
- **Prisma** + **SQLite** (local) — switch to Postgres/Supabase for production
- **Recharts** (radar chart), **jsPDF** (PDF export)
## Setup
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
pnpm install
cp .env.example .env
pnpm db:generate
pnpm db:push
pnpm db:seed
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
## Run
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
```bash
pnpm dev
```
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
Open [http://localhost:3000](http://localhost:3000).
## Learn More
## Seed Data
To learn more about Next.js, take a look at the following resources:
- **3 candidates** with sample evaluations (Alice Chen, Bob Martin, Carol White)
- **2 templates**: Full 15-dimensions, Short 8-dimensions
- **Admin user**: `admin@cars-front.local` (mock auth)
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
## API Routes
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
| Route | Method | Description |
|-------|--------|-------------|
| `/api/evaluations` | GET, POST | List / create evaluations |
| `/api/evaluations/[id]` | GET, PUT | Get / update evaluation |
| `/api/templates` | GET | List templates |
| `/api/export/csv?id=` | GET | Export evaluation as CSV |
| `/api/export/pdf?id=` | GET | Export evaluation as PDF |
| `/api/auth` | GET, POST | Mock auth |
| `/api/ai/suggest-followups` | POST | AI follow-up suggestions (stub) |
## Deploy on Vercel
## Export cURL Examples
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
```bash
# CSV export (replace EVAL_ID with actual evaluation id)
curl -o evaluation.csv "http://localhost:3000/api/export/csv?id=EVAL_ID"
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
# PDF export
curl -o evaluation.pdf "http://localhost:3000/api/export/pdf?id=EVAL_ID"
```
With auth header (when real auth is added):
```bash
curl -H "Authorization: Bearer YOUR_TOKEN" -o evaluation.csv "http://localhost:3000/api/export/csv?id=EVAL_ID"
```
## AI Assistant Stub
The AI assistant is a **client-side stub** that returns deterministic follow-up suggestions based on:
- Dimension name
- Candidate answer length
- Current score (low scores trigger probing questions)
**To plug a real LLM:**
1. Create or update `/api/ai/suggest-followups` to call OpenAI/Anthropic/etc.
2. Pass `{ dimensionName, candidateAnswer, currentScore }` in the request body.
3. Use a prompt like: *"Given this dimension and candidate answer, suggest 23 probing interview questions."*
4. Return `{ suggestions: string[] }`.
The client already calls this API when the user clicks "Get AI follow-up suggestions" in the dimension card.
## Tests
```bash
# Unit tests (Vitest)
pnpm test
# E2E tests (Playwright) — requires dev server
pnpm test:e2e
```
Run `pnpm exec playwright install` once to install browsers for E2E.
## Deploy
1. Set `DATABASE_URL` to Postgres (e.g. Supabase, Neon).
2. Run migrations: `pnpm db:push`
3. Seed if needed: `pnpm db:seed`
4. Build: `pnpm build && pnpm start`
5. Or deploy to Vercel (set env, use Vercel Postgres or external DB).
## Acceptance Criteria
- [x] Auth: mock single-admin login
- [x] Dashboard: list evaluations and candidates
- [x] Create/Edit evaluation: candidate, role, date, evaluator, template
- [x] Templates: Full 15-dim, Short 8-dim
- [x] Interview guide: definition, rubric 1→5, signals, questions per dimension
- [x] AI assistant: stub suggests follow-ups
- [x] Scoring: 15, justification, examples, confidence
- [x] Probing questions when score ≤ 2
- [x] Radar chart + findings/recommendations
- [x] Export PDF and CSV
- [x] Admin: view templates
- [x] Warning when all scores = 5 without comments
- [x] Edit after submission (audit log)
- [x] Mobile responsive (Tailwind)
## Manual Test Plan
1. **Dashboard**: Open `/`, verify evaluations table or empty state.
2. **New evaluation**: Click "New Evaluation", fill form, select template, submit.
3. **Interview guide**: On evaluation page, score dimensions, add notes, click "Get AI follow-up suggestions".
4. **Low score**: Set a dimension to 1 or 2, verify probing questions appear.
5. **All 5s**: Set all scores to 5 with no justification, submit — verify warning.
6. **Aggregate**: Click "Auto-generate findings", verify radar chart and text.
7. **Export**: Click Export, download CSV and PDF.
8. **Admin**: Open `/admin`, verify templates listed.
## File Structure
```
src/
├── app/
│ ├── api/ # API routes
│ ├── evaluations/ # Evaluation pages
│ ├── admin/ # Admin page
│ └── page.tsx # Dashboard
├── components/ # UI components
└── lib/ # Utils, db, ai-stub, export-utils
prisma/
├── schema.prisma
└── seed.ts
tests/e2e/ # Playwright E2E
```

View File

@@ -2,25 +2,45 @@
"name": "iag-dev-evaluator",
"version": "0.1.0",
"private": true,
"prisma": {
"seed": "tsx prisma/seed.ts"
},
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
"lint": "eslint",
"db:generate": "prisma generate",
"db:push": "prisma db push",
"db:seed": "tsx prisma/seed.ts",
"db:studio": "prisma studio",
"test": "vitest run",
"test:e2e": "playwright test"
},
"dependencies": {
"date-fns": "^4.1.0",
"jspdf": "^4.2.0",
"jspdf-autotable": "^5.0.7",
"next": "16.1.6",
"react": "19.2.3",
"react-dom": "19.2.3"
"react-dom": "19.2.3",
"recharts": "^3.7.0",
"@prisma/client": "^5.22.0"
},
"devDependencies": {
"@playwright/test": "^1.58.2",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitejs/plugin-react": "^5.1.4",
"eslint": "^9",
"eslint-config-next": "16.1.6",
"jsdom": "^28.1.0",
"prisma": "^5.22.0",
"tailwindcss": "^4",
"typescript": "^5"
"tsx": "^4.21.0",
"typescript": "^5",
"vitest": "^4.0.18"
}
}

20
playwright.config.ts Normal file
View File

@@ -0,0 +1,20 @@
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./tests/e2e",
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
reporter: "html",
use: {
baseURL: "http://localhost:3000",
trace: "on-first-retry",
},
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
webServer: {
command: "pnpm dev",
url: "http://localhost:3000",
reuseExistingServer: !process.env.CI,
},
});

1881
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

BIN
prisma/dev.db Normal file

Binary file not shown.

89
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,89 @@
// IA Gen Maturity Evaluator - Prisma Schema
// SQLite for local dev; switch to Postgres for production
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "sqlite"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
name String?
role String @default("evaluator") // evaluator | admin
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Template {
id String @id // "full-15"
name String
dimensions TemplateDimension[]
evaluations Evaluation[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model TemplateDimension {
id String @id @default(cuid())
templateId String
template Template @relation(fields: [templateId], references: [id], onDelete: Cascade)
slug String // "tools", "prompts", etc.
orderIndex Int
title String
rubric String // "1:X;2:Y;3:Z;4:A;5:B" or "1-5"
suggestedQuestions String? // JSON array: ["Q1", "Q2", "Q3"]
dimensionScores DimensionScore[]
@@unique([templateId, slug])
}
model Evaluation {
id String @id @default(cuid())
candidateName String
candidateRole String
evaluatorName String
evaluationDate DateTime
templateId String
template Template @relation(fields: [templateId], references: [id])
status String @default("draft") // draft | submitted
findings String? // auto-generated summary
recommendations String?
dimensionScores DimensionScore[]
auditLogs AuditLog[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model DimensionScore {
id String @id @default(cuid())
evaluationId String
evaluation Evaluation @relation(fields: [evaluationId], references: [id], onDelete: Cascade)
dimensionId String
dimension TemplateDimension @relation(fields: [dimensionId], references: [id], onDelete: Cascade)
score Int? // 1-5
justification String?
examplesObserved String?
confidence String? // low | med | high
candidateNotes String? // evaluator's notes from interview
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([evaluationId, dimensionId])
}
model AuditLog {
id String @id @default(cuid())
evaluationId String
evaluation Evaluation @relation(fields: [evaluationId], references: [id], onDelete: Cascade)
action String // created | updated | submitted | score_changed
field String?
oldValue String?
newValue String?
userId String?
createdAt DateTime @default(now())
}

222
prisma/seed.ts Normal file
View File

@@ -0,0 +1,222 @@
import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient();
const SUGGESTED_QUESTIONS: Record<string, string[]> = {
tools: [
"Quels outils IA utilisez-vous au quotidien ?",
"Comment les avez-vous choisis et intégrés à votre workflow ?",
"Utilisez-vous des rules, skills ou agents (Cursor, etc.) pour configurer vos outils ?",
"Partagez-vous une stack commune avec l'équipe ?",
],
prompts: [
"Comment structurez-vous vos prompts pour des tâches complexes ?",
"Utilisez-vous des templates ou des patterns réutilisables ?",
"Comment gérez-vous les cas limites et les outputs inattendus ?",
],
context: [
"Quel contexte fournissez-vous typiquement à l'IA ?",
"Comment décidez-vous ce qui est pertinent à inclure ?",
"Avez-vous des stratégies pour limiter le contexte tout en restant pertinent ?",
],
iteration: [
"Comment itérez-vous quand la première réponse ne convient pas ?",
"Décomposez-vous les tâches complexes en sous-étapes ou sous-agents ?",
"Utilisez-vous l'IA comme sparring partner pour réfléchir ?",
"Avez-vous des workflows agentiques (agents, sous-agents) ?",
],
evaluation: [
"Comment vérifiez-vous les sorties de l'IA avant de les utiliser ?",
"Avez-vous des critères explicites de qualité ?",
"Comment gérez-vous les hallucinations ou erreurs subtiles ?",
],
integration: [
"Comment l'IA est-elle discutée et partagée dans l'équipe ?",
"Y a-t-il des pratiques formalisées ou documentées ?",
"Comment les nouveaux arrivants sont-ils onboardés sur ces usages ?",
],
usecases: [
"Quels cas d'usage couvrez-vous (snippets, tests, refacto, debug...) ?",
"Quel est votre cas d'usage principal ?",
"Avez-vous exploré des usages plus avancés (discovery, review, agents, skills) ?",
],
impact: [
"Quel impact mesurable l'IA a-t-elle sur votre delivery ?",
"Comment le quantifiez-vous (temps, qualité, vélocité) ?",
"L'IA est-elle un levier stratégique pour l'équipe ?",
],
risks: [
"Quelles précautions prenez-vous (sécurité, confidentialité, biais) ?",
"Y a-t-il des règles partagées ou une doctrine ?",
"Comment gérez-vous les risques liés aux données sensibles ?",
],
alignment: [
"Comment vous assurez-vous que le code généré respecte vos standards ?",
"Avez-vous des garde-fous pour l'alignement archi ?",
"Comment gérez-vous le rework quand l'IA sort du cadre ?",
],
code_quality: [
"Quelle qualité de code attendez-vous des sorties IA ?",
"Comment validez-vous la maintenabilité ?",
"Avez-vous des exemples où le code généré était fragile ?",
],
quality_usage: [
"Utilisez-vous l'IA pour les tests ? Comment ?",
"Et pour la revue de code ou le refactoring ?",
"L'IA est-elle un levier pour améliorer la qualité globale ?",
],
capitalization: [
"Comment capitalisez-vous les prompts, rules, skills et bonnes pratiques ?",
"Y a-t-il une base partagée (rules, skills, wiki) ?",
"Comment partagez-vous les retours d'expérience ?",
],
learning: [
"Comment l'IA vous aide-t-elle à monter en compétence ?",
"Utilisez-vous l'IA pour comprendre des patterns ou concepts ?",
"Évitez-vous la dépendance passive (copier-coller sans comprendre) ?",
],
measurement: [
"Comment mesurez-vous l'usage et l'impact de l'IA ?",
"Avez-vous des indicateurs ou du feedback utilisateur ?",
"Comment pilotez-vous l'adoption et l'amélioration ?",
],
};
const RUBRICS: Record<string, string> = {
tools: "1:Usage ponctuel — utilisation occasionnelle, sans réelle stratégie ni critères de choix;2:Outil identifié — un outil principal utilisé, choix fait de manière informelle;3:Usage raisonné — comparaison de plusieurs outils, critères explicites (coût, latence, qualité);4:Stack partagée — stack commune à l'équipe, documentée et maintenue;5:Intégré au workflow — adoption généralisée",
prompts: "1:Vague — prompts improvisés, peu de structure, résultats aléatoires;2:Simple — prompts basiques avec instructions claires mais sans systématisation;3:Structuré — format cohérent (rôle, contexte, tâche, format attendu), testés manuellement;4:Templates — bibliothèque de prompts réutilisables, versionnés;5:Prompt engineering maîtrisé — techniques avancées (chain-of-thought, few-shot), optimisation continue, validation des outputs",
context: "1:Peu — contexte minimal fourni, l'IA manque d'informations pour bien répondre;2:Partiel — contexte partiel, parfois pertinent, parfois manquant;3:Suffisant — contexte adapté à la tâche, couvre les éléments essentiels;4:Structuré — contexte organisé (sections, priorités), stratégie de sélection;5:Contextualisation avancée — RAG, embedding, contexte dynamique selon la requête",
iteration: "1:One-shot — une seule tentative, pas de retry si le résultat est insuffisant;2:Quelques itérations — 2-3 essais manuels si la première réponse échoue;3:Itératif — approche systématique: retry, reformulation, décomposition en sous-tâches;4:Décomposition — tâches complexes découpées en étapes, prompts en chaîne;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é, alignement, cohérence;5:Culture critique — critères de qualité partagés, revue systématique, détection des hallucinations",
integration: "1:Isolé — usage personnel, pas de partage ou discussion en équipe;2:Discussions — échanges informels sur les usages, pas de formalisation;3:Partages — démos, retours d'expérience, bonnes pratiques partagées;4:Formalisé — pratiques documentées, onboarding, standards d'usage;5:Doctrine équipe — vision partagée, roadmap IA, adoption comme pilier de la stratégie",
usecases: "1:Snippets — usage limité à la génération de petits snippets ou complétion;2:Code basique — génération de fonctions, classes, scripts simples;3:Tests/refacto — génération de tests, refactoring, documentation;4:Debug/opt — aide au debug, optimisation, analyse de code;5:Discovery→review — exploration de solutions, design, revue de code, boucle complète",
impact: "1:Aucun — pas d'impact visible sur la delivery;2:Marginal — gain de temps perçu mais non quantifié;3:Accélération — vélocité accrue, moins de tâches répétitives;4:Gain mesurable — métriques (temps, qualité, vélocité) documentées;5:Levier clair — IA comme levier stratégique, pilotage de l'adoption, ROI démontré",
risks: "1:Aucune — pas de considération des risques (sécurité, confidentialité, biais);2:Sensibilisation — conscience des risques, pas de processus formalisé;3:Bonnes pratiques — précautions appliquées (données, prompts, sanitization);4:Règles partagées — règles d'usage, checklist, validation des données;5:Doctrine — politique de sécurité IA, gouvernance, revue des risques",
alignment: "1:Hors standards — code généré souvent non conforme, rework systématique;2:Rework lourd — modifications importantes nécessaires pour aligner;3:Cohérent — code généralement aligné, quelques ajustements;4:Aligné — prompts et garde-fous pour respecter standards et archi;5:Quasi conforme — sorties quasi conformes, validation automatisée possible",
code_quality: "1:Peu maintenable — code fragile, difficile à faire évoluer;2:Correct fragile — fonctionne mais cas limites non gérés;3:Maintenable — code propre, testable, évolutif;4:Propre structuré — patterns respectés, séparation des responsabilités;5:Quasi senior — qualité équivalente à un code produit par un dev senior",
quality_usage: "1:Rarement — utilisation peu fréquente pour la qualité;2:Tests simples — génération de tests unitaires basiques;3:Tests utiles — tests pertinents, couverture, refacto assistée;4:Refacto guidée — IA pour identifier du code à améliorer, suggérer des refactorings;5:Levier qualité — IA intégrée dans la boucle qualité (review, dette technique, standards)",
capitalization: "1:None — pas de capitalisation, tout est dans la tête ou éparpillé;2:Informel — notes personnelles, partage oral;3:Bonnes pratiques — document informal, exemples partagés;4:Base prompts — bibliothèque de prompts, wiki interne;5:Wiki & REX — base documentée, retours d'expérience, amélioration continue",
learning: "1:Dépendance — copier-coller sans comprendre, risque de régression;2:Apprentissage limité — utilisation pour débloquer mais compréhension superficielle;3:Compréhension — IA pour comprendre les concepts, valider sa compréhension;4:IA pour patterns — utilisation pour apprendre des patterns, architectures, bonnes pratiques;5:Accélérateur de progression — IA comme outil de montée en compétence structurée",
measurement: "1:Aucun suivi — pas de mesure de l'usage ou de l'impact;2:Perception — sentiment d'impact, pas de données;3:Feedback — retours utilisateurs, observations qualitatives;4:Indicateurs simples — métriques d'usage (adoption, volume), premiers KPIs;5:Pilotage structuré — tableau de bord, suivi de l'adoption, pilotage de l'amélioration",
};
const TEMPLATES_DATA = [
{
id: "full-15",
name: "Full - 15 dimensions",
dimensions: [
{ id: "tools", title: "Choix & maîtrise des outils", rubric: RUBRICS.tools },
{ id: "prompts", title: "Clarté des prompts", rubric: RUBRICS.prompts },
{ id: "context", title: "Pertinence du contexte fourni", rubric: RUBRICS.context },
{ id: "iteration", title: "Capacité d'itération", rubric: RUBRICS.iteration },
{ id: "evaluation", title: "Évaluation critique", rubric: RUBRICS.evaluation },
{ id: "integration", title: "Intégration dans les pratiques d'équipe", rubric: RUBRICS.integration },
{ id: "usecases", title: "Cas d'usage couverts", rubric: RUBRICS.usecases },
{ id: "impact", title: "Impact sur la delivery", rubric: RUBRICS.impact },
{ id: "risks", title: "Gestion risques & sécurité", rubric: RUBRICS.risks },
{ id: "alignment", title: "Alignement archi & standards", rubric: RUBRICS.alignment },
{ id: "code_quality", title: "Qualité du code généré", rubric: RUBRICS.code_quality },
{ id: "quality_usage", title: "Usage pour la qualité (tests, review)", rubric: RUBRICS.quality_usage },
{ id: "capitalization", title: "Capitalisation & partage", rubric: RUBRICS.capitalization },
{ id: "learning", title: "Montée en compétence via IA", rubric: RUBRICS.learning },
{ id: "measurement", title: "Mesure & pilotage", rubric: RUBRICS.measurement },
],
},
];
async function main() {
// Sync templates & dimensions only — ne touche pas aux évaluations, users, audit logs
for (const t of TEMPLATES_DATA) {
const template = await prisma.template.upsert({
where: { id: t.id },
create: { id: t.id, name: t.name },
update: { name: t.name },
});
for (let i = 0; i < t.dimensions.length; i++) {
const d = t.dimensions[i];
const questions = SUGGESTED_QUESTIONS[d.id];
await prisma.templateDimension.upsert({
where: {
templateId_slug: { templateId: template.id, slug: d.id },
},
create: {
templateId: template.id,
slug: d.id,
orderIndex: i,
title: d.title,
rubric: d.rubric,
suggestedQuestions: questions ? JSON.stringify(questions) : null,
},
update: {
orderIndex: i,
title: d.title,
rubric: d.rubric,
suggestedQuestions: questions ? JSON.stringify(questions) : null,
},
});
}
}
// Bootstrap demo data uniquement si la DB est vide
const evalCount = await prisma.evaluation.count();
if (evalCount === 0) {
const template = await prisma.template.findUnique({ where: { id: "full-15" } });
if (!template) throw new Error("Template not found");
await prisma.user.upsert({
where: { email: "admin@cars-front.local" },
create: { email: "admin@cars-front.local", name: "Admin User", role: "admin" },
update: {},
});
const dims = await prisma.templateDimension.findMany({
where: { templateId: template.id },
orderBy: { orderIndex: "asc" },
});
const candidates = [
{ name: "Alice Chen", role: "Senior ML Engineer", evaluator: "Jean Dupont" },
{ name: "Bob Martin", role: "Data Scientist", evaluator: "Marie Curie" },
{ name: "Carol White", role: "AI Product Manager", evaluator: "Jean Dupont" },
];
for (let i = 0; i < candidates.length; i++) {
const c = candidates[i];
const evaluation = await prisma.evaluation.create({
data: {
candidateName: c.name,
candidateRole: c.role,
evaluatorName: c.evaluator,
evaluationDate: new Date(2025, 1, 15 + i),
templateId: template.id,
status: i === 0 ? "submitted" : "draft",
findings: i === 0 ? "Bonne maîtrise des outils et des prompts. Axes d'amélioration : capitalisation et mesure." : null,
recommendations: i === 0 ? "Former sur la capitalisation des prompts et mettre en place des indicateurs." : null,
},
});
for (const d of dims) {
const score = 2 + Math.floor(Math.random() * 3);
await prisma.dimensionScore.create({
data: {
evaluationId: evaluation.id,
dimensionId: d.id,
score,
justification: `Justification pour ${d.title}`,
examplesObserved: `Observé : ${d.title} niveau ${score}`,
confidence: ["low", "med", "high"][Math.floor(Math.random() * 3)],
},
});
}
}
console.log("Seed complete: templates synced, demo evaluations created");
} else {
console.log("Seed complete: templates synced, evaluations preserved");
}
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());

66
src/app/admin/page.tsx Normal file
View File

@@ -0,0 +1,66 @@
"use client";
import { useState, useEffect } from "react";
interface Template {
id: string;
name: string;
dimensions: { id: string; title: string; orderIndex: number }[];
}
export default function AdminPage() {
const [templates, setTemplates] = useState<Template[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch("/api/templates")
.then((r) => r.json())
.then(setTemplates)
.finally(() => setLoading(false));
}, []);
if (loading) {
return <div className="py-12 text-center font-mono text-xs text-zinc-600 dark:text-zinc-500">loading...</div>;
}
return (
<div>
<h1 className="mb-6 font-mono text-lg font-medium text-zinc-800 dark:text-zinc-200">Admin</h1>
<p className="mb-6 font-mono text-xs text-zinc-600 dark:text-zinc-500">
Modèles. CRUD via /api/templates
</p>
<section>
<h2 className="mb-4 font-mono text-xs text-zinc-600 dark:text-zinc-500">Modèles</h2>
<div className="space-y-3">
{templates.map((t) => (
<div
key={t.id}
className="rounded-lg border border-zinc-200 dark:border-zinc-600 bg-white dark:bg-zinc-800 p-4 shadow-sm dark:shadow-none"
>
<h3 className="font-medium text-zinc-800 dark:text-zinc-200">{t.name}</h3>
<p className="mt-1 font-mono text-xs text-zinc-600 dark:text-zinc-500">
{t.dimensions.length} dim.
</p>
<ul className="mt-2 space-y-0.5 font-mono text-xs text-zinc-600 dark:text-zinc-400">
{t.dimensions.slice(0, 5).map((d) => (
<li key={d.id}> {d.title}</li>
))}
{t.dimensions.length > 5 && (
<li className="text-zinc-600 dark:text-zinc-500">+{t.dimensions.length - 5}</li>
)}
</ul>
</div>
))}
</div>
</section>
<section className="mt-8">
<h2 className="mb-2 font-mono text-xs text-zinc-600 dark:text-zinc-500">Users</h2>
<p className="font-mono text-xs text-zinc-600 dark:text-zinc-500">
admin@cars-front.local
</p>
</section>
</div>
);
}

38
src/app/api/auth/route.ts Normal file
View File

@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from "next/server";
/**
* Mock auth - MVP: single admin login
* In production: use NextAuth, Clerk, or Supabase Auth
*/
const MOCK_ADMIN = {
email: "admin@cars-front.local",
name: "Admin User",
role: "admin",
};
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const { email, password } = body;
// Accept any email for MVP; in prod validate against DB
if (!email) {
return NextResponse.json({ error: "Email required" }, { status: 400 });
}
// Mock: accept "admin" or any password for dev
const user = {
...MOCK_ADMIN,
email: email || MOCK_ADMIN.email,
};
return NextResponse.json({ user, token: "mock-jwt-" + Date.now() });
} catch {
return NextResponse.json({ error: "Auth failed" }, { status: 500 });
}
}
export async function GET() {
// Check session - mock: always return admin
return NextResponse.json({ user: MOCK_ADMIN });
}

View File

@@ -0,0 +1,176 @@
import { NextRequest, NextResponse } from "next/server";
import { Prisma } from "@prisma/client";
import { prisma } from "@/lib/db";
export async function GET(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const evaluation = await prisma.evaluation.findUnique({
where: { id },
include: {
template: {
include: {
dimensions: { orderBy: { orderIndex: "asc" } },
},
},
dimensionScores: { include: { dimension: true } },
},
});
if (!evaluation) {
return NextResponse.json({ error: "Evaluation not found" }, { status: 404 });
}
// Prisma ORM omits suggestedQuestions in some contexts — fetch via raw
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]));
const out = {
...evaluation,
template: evaluation.template
? {
...evaluation.template,
dimensions: evaluation.template.dimensions.map((d) => {
const raw = dimMap.get(d.id);
return {
id: d.id,
slug: d.slug,
title: d.title,
rubric: d.rubric,
orderIndex: d.orderIndex,
suggestedQuestions: raw?.suggestedQuestions ?? d.suggestedQuestions,
};
}),
}
: null,
dimensionScores: evaluation.dimensionScores.map((ds) => ({
...ds,
dimension: ds.dimension
? {
id: ds.dimension.id,
slug: ds.dimension.slug,
title: ds.dimension.title,
rubric: ds.dimension.rubric,
suggestedQuestions: dimMap.get(ds.dimension.id)?.suggestedQuestions ?? ds.dimension.suggestedQuestions,
}
: null,
})),
};
return NextResponse.json(out);
} catch (e) {
console.error(e);
return NextResponse.json({ error: "Failed to fetch evaluation" }, { status: 500 });
}
}
export async function PUT(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const body = await req.json();
const { candidateName, candidateRole, evaluatorName, evaluationDate, status, findings, recommendations, dimensionScores } = body;
const existing = await prisma.evaluation.findUnique({ where: { id } });
if (!existing) {
return NextResponse.json({ error: "Evaluation not found" }, { status: 404 });
}
const updateData: Record<string, unknown> = {};
if (candidateName != null) updateData.candidateName = candidateName;
if (candidateRole != null) updateData.candidateRole = candidateRole;
if (evaluatorName != null) updateData.evaluatorName = evaluatorName;
if (evaluationDate != null) updateData.evaluationDate = new Date(evaluationDate);
if (status != null) updateData.status = status;
if (findings != null) updateData.findings = findings;
if (recommendations != null) updateData.recommendations = recommendations;
if (Object.keys(updateData).length > 0) {
await prisma.auditLog.create({
data: {
evaluationId: id,
action: "updated",
newValue: JSON.stringify(updateData),
},
});
}
const evaluation = await prisma.evaluation.update({
where: { id },
data: updateData,
include: {
template: { include: { dimensions: { orderBy: { orderIndex: "asc" } } } },
dimensionScores: { include: { dimension: true } },
},
});
if (dimensionScores && Array.isArray(dimensionScores)) {
for (const ds of dimensionScores) {
if (ds.dimensionId) {
await prisma.dimensionScore.upsert({
where: {
evaluationId_dimensionId: {
evaluationId: id,
dimensionId: ds.dimensionId,
},
},
update: {
score: ds.score,
justification: ds.justification,
examplesObserved: ds.examplesObserved,
confidence: ds.confidence,
candidateNotes: ds.candidateNotes,
},
create: {
evaluationId: id,
dimensionId: ds.dimensionId,
score: ds.score,
justification: ds.justification,
examplesObserved: ds.examplesObserved,
confidence: ds.confidence,
candidateNotes: ds.candidateNotes,
},
});
}
}
}
const updated = await prisma.evaluation.findUnique({
where: { id },
include: {
template: { include: { dimensions: { orderBy: { orderIndex: "asc" } } } },
dimensionScores: { include: { dimension: true } },
},
});
return NextResponse.json(updated);
} catch (e) {
console.error(e);
return NextResponse.json({ error: "Failed to update evaluation" }, { status: 500 });
}
}
export async function DELETE(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
await prisma.evaluation.delete({ where: { id } });
return NextResponse.json({ ok: true });
} catch (e) {
console.error(e);
return NextResponse.json({ error: "Failed to delete evaluation" }, { status: 500 });
}
}

View File

@@ -0,0 +1,87 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url);
const status = searchParams.get("status");
const templateId = searchParams.get("templateId");
const evaluations = await prisma.evaluation.findMany({
where: {
...(status && { status }),
...(templateId && { templateId }),
},
include: {
template: true,
dimensionScores: { include: { dimension: true } },
},
orderBy: { evaluationDate: "desc" },
});
return NextResponse.json(evaluations);
} catch (e) {
console.error(e);
return NextResponse.json({ error: "Failed to fetch evaluations" }, { status: 500 });
}
}
export async function POST(req: NextRequest) {
try {
const body = await req.json();
const { candidateName, candidateRole, evaluatorName, evaluationDate, templateId } = body;
if (!candidateName || !candidateRole || !evaluatorName || !evaluationDate || !templateId) {
return NextResponse.json(
{ error: "Missing required fields: candidateName, candidateRole, evaluatorName, evaluationDate, templateId" },
{ status: 400 }
);
}
const template = await prisma.template.findUnique({
where: { id: templateId },
include: { dimensions: { orderBy: { orderIndex: "asc" } } },
});
if (!template) {
return NextResponse.json({ error: "Template not found" }, { status: 404 });
}
const evaluation = await prisma.evaluation.create({
data: {
candidateName,
candidateRole,
evaluatorName,
evaluationDate: new Date(evaluationDate),
templateId,
status: "draft",
},
include: {
template: true,
dimensionScores: { include: { dimension: true } },
},
});
// Create empty dimension scores for each template dimension
for (const dim of template.dimensions) {
await prisma.dimensionScore.create({
data: {
evaluationId: evaluation.id,
dimensionId: dim.id,
},
});
}
const updated = await prisma.evaluation.findUnique({
where: { id: evaluation.id },
include: {
template: true,
dimensionScores: { include: { dimension: true } },
},
});
return NextResponse.json(updated);
} catch (e) {
console.error(e);
return NextResponse.json({ error: "Failed to create evaluation" }, { status: 500 });
}
}

View File

@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { evaluationToCsvRows } from "@/lib/export-utils";
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url);
const id = searchParams.get("id");
if (!id) {
return NextResponse.json({ error: "Evaluation id required" }, { status: 400 });
}
const evaluation = await prisma.evaluation.findUnique({
where: { id },
include: {
template: true,
dimensionScores: { include: { dimension: true } },
},
});
if (!evaluation) {
return NextResponse.json({ error: "Evaluation not found" }, { status: 404 });
}
const rows = evaluationToCsvRows(evaluation as Parameters<typeof evaluationToCsvRows>[0]);
const csv = rows.map((r) => r.map((c) => `"${String(c).replace(/"/g, '""')}"`).join(",")).join("\n");
return new NextResponse(csv, {
headers: {
"Content-Type": "text/csv",
"Content-Disposition": `attachment; filename="evaluation-${id}.csv"`,
},
});
} catch (e) {
console.error(e);
return NextResponse.json({ error: "Export failed" }, { status: 500 });
}
}

View File

@@ -0,0 +1,68 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { jsPDF } from "jspdf";
import autoTable from "jspdf-autotable";
import { format } from "date-fns";
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url);
const id = searchParams.get("id");
if (!id) {
return NextResponse.json({ error: "Evaluation id required" }, { status: 400 });
}
const evaluation = await prisma.evaluation.findUnique({
where: { id },
include: {
template: true,
dimensionScores: { include: { dimension: true } },
},
});
if (!evaluation) {
return NextResponse.json({ error: "Evaluation not found" }, { status: 404 });
}
const doc = new jsPDF();
doc.setFontSize(18);
doc.text("Évaluation Maturité IA Gen", 14, 20);
doc.setFontSize(10);
doc.text(`Candidat : ${evaluation.candidateName} | Rôle : ${evaluation.candidateRole}`, 14, 28);
doc.text(`Évaluateur : ${evaluation.evaluatorName} | Date : ${format(evaluation.evaluationDate, "yyyy-MM-dd")}`, 14, 34);
doc.text(`Modèle : ${evaluation.template?.name ?? ""} | Statut : ${evaluation.status === "submitted" ? "Soumise" : "Brouillon"}`, 14, 40);
const tableData = evaluation.dimensionScores.map((ds) => [
ds.dimension.title,
String(ds.score ?? "-"),
ds.justification ?? "",
ds.confidence ?? "",
]);
autoTable(doc, {
startY: 48,
head: [["Dimension", "Score", "Justification", "Confiance"]],
body: tableData,
theme: "striped",
});
const finalY =
(doc as unknown as { lastAutoTable?: { finalY: number } }).lastAutoTable?.finalY ?? 48;
doc.setFontSize(10);
doc.text("Synthèse :", 14, finalY + 12);
doc.text(evaluation.findings ?? "N/A", 14, finalY + 18, { maxWidth: 180 });
doc.text("Recommandations :", 14, finalY + 28);
doc.text(evaluation.recommendations ?? "N/A", 14, finalY + 34, { maxWidth: 180 });
const buf = Buffer.from(doc.output("arraybuffer"));
return new NextResponse(buf, {
headers: {
"Content-Type": "application/pdf",
"Content-Disposition": `attachment; filename="evaluation-${id}.pdf"`,
},
});
} catch (e) {
console.error(e);
return NextResponse.json({ error: "Export failed" }, { status: 500 });
}
}

View File

@@ -0,0 +1,29 @@
import { NextResponse } from "next/server";
import { prisma } from "@/lib/db";
/** Returns templates in the canonical JSON format: { templates: [{ id, name, dimensions: [{ id, title, rubric }] }] } */
export async function GET() {
try {
const templates = await prisma.template.findMany({
include: {
dimensions: { orderBy: { orderIndex: "asc" } },
},
});
const data = {
templates: templates.map((t) => ({
id: t.id,
name: t.name,
dimensions: t.dimensions.map((d) => ({
id: d.slug,
title: d.title,
rubric: d.rubric,
suggestedQuestions: d.suggestedQuestions,
})),
})),
};
return NextResponse.json(data);
} catch (e) {
console.error(e);
return NextResponse.json({ error: "Failed to fetch templates" }, { status: 500 });
}
}

View File

@@ -0,0 +1,29 @@
import { NextResponse } from "next/server";
import { Prisma } from "@prisma/client";
import { prisma } from "@/lib/db";
export async function GET() {
try {
const templates = await prisma.template.findMany({
include: {
dimensions: { orderBy: { orderIndex: "asc" } },
},
});
// Prisma ORM omits suggestedQuestions — enrich via raw
const dimsRaw = (await prisma.$queryRaw(
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]));
const out = templates.map((t) => ({
...t,
dimensions: t.dimensions.map((d) => ({
...d,
suggestedQuestions: dimMap.get(d.id)?.suggestedQuestions ?? d.suggestedQuestions,
})),
}));
return NextResponse.json(out);
} catch (e) {
console.error(e);
return NextResponse.json({ error: "Failed to fetch templates" }, { status: 500 });
}
}

View File

@@ -0,0 +1,361 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useParams, useRouter } from "next/navigation";
import { CandidateForm } from "@/components/CandidateForm";
import { DimensionCard } from "@/components/DimensionCard";
import { RadarChart } from "@/components/RadarChart";
import { ExportModal } from "@/components/ExportModal";
import { ConfirmModal } from "@/components/ConfirmModal";
import { generateFindings, generateRecommendations, computeAverageScore } from "@/lib/export-utils";
interface Dimension {
id: string;
slug: string;
title: string;
rubric: string;
suggestedQuestions?: string | null;
}
interface DimensionScore {
id: string;
dimensionId: string;
score: number | null;
justification: string | null;
examplesObserved: string | null;
confidence: string | null;
candidateNotes: string | null;
dimension: Dimension;
}
interface Evaluation {
id: string;
candidateName: string;
candidateRole: string;
evaluatorName: string;
evaluationDate: string;
templateId: string;
template: { id: string; name: string; dimensions: Dimension[] };
status: string;
findings: string | null;
recommendations: string | null;
dimensionScores: DimensionScore[];
}
export default function EvaluationDetailPage() {
const params = useParams();
const router = useRouter();
const id = params.id as string;
const [evaluation, setEvaluation] = useState<Evaluation | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [templates, setTemplates] = useState<{ id: string; name: string }[]>([]);
const [exportOpen, setExportOpen] = useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const fetchEval = useCallback(() => {
setLoading(true);
Promise.all([
fetch(`/api/evaluations/${id}`).then((r) => r.json()),
fetch("/api/templates").then((r) => r.json()),
])
.then(([evalData, templatesData]) => {
setTemplates(Array.isArray(templatesData) ? templatesData : []);
if (evalData?.error) {
setEvaluation(null);
return;
}
try {
if (evalData?.template?.dimensions?.length > 0 && Array.isArray(templatesData)) {
const tmpl = templatesData.find((t: { id: string }) => t.id === evalData.templateId);
if (tmpl?.dimensions?.length) {
const dimMap = new Map(tmpl.dimensions.map((d: { id: string }) => [d.id, d]));
evalData.template.dimensions = evalData.template.dimensions.map((d: { id: string }) => ({
...d,
suggestedQuestions: d.suggestedQuestions ?? dimMap.get(d.id)?.suggestedQuestions,
}));
}
}
} catch {
/* merge failed, use evalData as-is */
}
setEvaluation({ ...evalData, dimensionScores: evalData.dimensionScores ?? [] });
})
.catch(() => setEvaluation(null))
.finally(() => setLoading(false));
}, [id]);
useEffect(() => {
fetchEval();
}, [fetchEval]);
// Draft backup to localStorage (debounced, for offline resilience)
useEffect(() => {
if (!evaluation || !id) return;
const t = setTimeout(() => {
try {
localStorage.setItem(
`eval-draft-${id}`,
JSON.stringify({ ...evaluation, evaluationDate: evaluation.evaluationDate })
);
} catch {
/* ignore */
}
}, 2000);
return () => clearTimeout(t);
}, [evaluation, id]);
const handleFormChange = (field: string, value: string) => {
if (!evaluation) return;
setEvaluation((e) => (e ? { ...e, [field]: value } : null));
};
const handleScoreChange = (dimensionId: string, data: Partial<DimensionScore>) => {
if (!evaluation) return;
setEvaluation((e) => {
if (!e) return null;
const scores = e.dimensionScores.map((ds) =>
ds.dimensionId === dimensionId ? { ...ds, ...data } : ds
);
return { ...e, dimensionScores: scores };
});
};
const handleSave = async () => {
if (!evaluation) return;
setSaving(true);
try {
const res = await fetch(`/api/evaluations/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
candidateName: evaluation.candidateName,
candidateRole: evaluation.candidateRole,
evaluatorName: evaluation.evaluatorName,
evaluationDate: evaluation.evaluationDate,
status: evaluation.status,
findings: evaluation.findings,
recommendations: evaluation.recommendations,
dimensionScores: (evaluation.dimensionScores ?? []).map((ds) => ({
dimensionId: ds.dimensionId,
evaluationId: id,
score: ds.score,
justification: ds.justification,
examplesObserved: ds.examplesObserved,
confidence: ds.confidence,
candidateNotes: ds.candidateNotes,
})),
}),
});
if (res.ok) {
fetchEval();
} else {
const data = await res.json();
alert(data.error ?? "Save failed");
}
} finally {
setSaving(false);
}
};
const handleGenerateFindings = () => {
if (!evaluation) return;
const findings = generateFindings(evaluation.dimensionScores ?? []);
const recommendations = generateRecommendations(evaluation.dimensionScores ?? []);
setEvaluation((e) => (e ? { ...e, findings, recommendations } : null));
};
const allFives = evaluation?.dimensionScores?.every(
(ds) => ds.score === 5 && (!ds.justification || ds.justification.trim() === "")
);
const showAllFivesWarning = allFives && evaluation?.status === "submitted";
if (loading) {
return <div className="py-12 text-center font-mono text-xs text-zinc-600 dark:text-zinc-500">loading...</div>;
}
if (!evaluation) {
return (
<div className="py-12 text-center font-mono text-xs text-zinc-600 dark:text-zinc-500">
Évaluation introuvable.{" "}
<a href="/" className="text-cyan-600 dark:text-cyan-400 hover:underline">
dashboard
</a>
</div>
);
}
const dimensions = evaluation.template?.dimensions ?? [];
const dimensionScores = evaluation.dimensionScores ?? [];
const scoreMap = new Map(dimensionScores.map((ds) => [ds.dimensionId, ds]));
const radarData = dimensionScores
.filter((ds) => ds.score != null)
.map((ds) => {
const title = ds.dimension?.title ?? "";
return {
dimension: title.length > 12 ? title.slice(0, 12) + "…" : title,
score: ds.score ?? 0,
fullMark: 5,
};
});
const avgScore = computeAverageScore(dimensionScores);
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<h1 className="font-mono text-base font-medium text-zinc-800 dark:text-zinc-100">
{evaluation.candidateName} <span className="text-zinc-500">/</span> {evaluation.candidateRole}
</h1>
<div className="flex gap-2">
<button
onClick={handleSave}
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"
>
{saving ? "..." : "save"}
</button>
<button
onClick={() => setExportOpen(true)}
className="rounded border border-cyan-500/50 bg-cyan-500/10 px-3 py-1.5 font-mono text-xs text-cyan-600 dark:text-cyan-400 hover:bg-cyan-500/20"
>
export
</button>
</div>
</div>
{showAllFivesWarning && (
<div className="rounded border border-amber-500/30 bg-amber-500/10 p-3 font-mono text-xs text-amber-600 dark:text-amber-400">
Tous les scores = 5 sans justification
</div>
)}
<section className="rounded-lg border border-zinc-200 dark:border-zinc-600 bg-white dark:bg-zinc-800 p-4 shadow-sm dark:shadow-none">
<h2 className="mb-3 font-mono text-xs text-zinc-600 dark:text-zinc-500">Session</h2>
<CandidateForm
candidateName={evaluation.candidateName}
candidateRole={evaluation.candidateRole}
evaluatorName={evaluation.evaluatorName}
evaluationDate={evaluation.evaluationDate.split("T")[0]}
templateId={evaluation.templateId}
templates={templates}
onChange={handleFormChange}
templateDisabled
/>
</section>
<section>
<h2 className="mb-3 font-mono text-xs text-zinc-600 dark:text-zinc-500">Dimensions</h2>
<nav className="mb-4 flex flex-wrap gap-1.5">
{dimensions.map((dim, i) => {
const ds = scoreMap.get(dim.id);
const hasScore = ds?.score != null;
return (
<a
key={dim.id}
href={`#dim-${dim.id}`}
className={`rounded px-2 py-0.5 font-mono text-xs transition-colors ${
hasScore
? "bg-cyan-500/20 text-cyan-600 dark:text-cyan-400 hover:bg-cyan-500/30"
: "text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800 hover:text-zinc-700 dark:hover:text-zinc-300"
}`}
>
{i + 1}. {dim.title}
</a>
);
})}
</nav>
<div className="space-y-2">
{dimensions.map((dim, i) => (
<div key={dim.id} id={`dim-${dim.id}`} className="scroll-mt-24">
<DimensionCard
dimension={dim}
index={i}
score={scoreMap.get(dim.id) ?? null}
onScoreChange={handleScoreChange}
/>
</div>
))}
</div>
</section>
<section className="rounded-lg border border-zinc-200 dark:border-zinc-600 bg-white dark:bg-zinc-800 p-4 shadow-sm dark:shadow-none">
<h2 className="mb-3 font-mono text-xs text-zinc-600 dark:text-zinc-500">Synthèse</h2>
<p className="mb-4 font-mono text-sm text-zinc-700 dark:text-zinc-300">
Moyenne <span className="text-cyan-600 dark:text-cyan-400">{avgScore.toFixed(1)}/5</span>
</p>
<RadarChart data={radarData} />
<div className="mt-4 grid gap-4 sm:grid-cols-2">
<div>
<label className="mb-1 block text-xs text-zinc-600 dark:text-zinc-500">Synthèse</label>
<textarea
value={evaluation.findings ?? ""}
onChange={(e) => setEvaluation((ev) => (ev ? { ...ev, findings: e.target.value } : null))}
rows={3}
className="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"
/>
</div>
<div>
<label className="mb-1 block text-xs text-zinc-600 dark:text-zinc-500">Recommandations</label>
<textarea
value={evaluation.recommendations ?? ""}
onChange={(e) =>
setEvaluation((ev) => (ev ? { ...ev, recommendations: e.target.value } : null))
}
rows={3}
className="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"
/>
</div>
</div>
<button
type="button"
onClick={handleGenerateFindings}
className="mt-2 font-mono text-xs text-cyan-600 dark:text-cyan-400 hover:text-cyan-500 dark:hover:text-cyan-300"
>
auto-générer
</button>
</section>
<div className="flex flex-wrap gap-3">
<button
onClick={() => {
setEvaluation((e) => (e ? { ...e, status: "submitted" } : null));
setTimeout(handleSave, 0);
}}
className="rounded border border-emerald-500/50 bg-emerald-500/20 px-3 py-1.5 font-mono text-xs text-emerald-600 dark:text-emerald-400 hover:bg-emerald-500/30"
>
soumettre
</button>
<button onClick={() => router.push("/")} className="rounded border border-zinc-300 dark:border-zinc-600 px-3 py-1.5 font-mono text-xs text-zinc-600 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700">
dashboard
</button>
<button
type="button"
onClick={() => setDeleteConfirmOpen(true)}
className="rounded border border-red-500/30 px-3 py-1.5 font-mono text-xs text-red-600 dark:text-red-400 hover:bg-red-500/10"
>
supprimer
</button>
</div>
<ExportModal
isOpen={exportOpen}
onClose={() => setExportOpen(false)}
evaluationId={id}
/>
<ConfirmModal
isOpen={deleteConfirmOpen}
title="Supprimer l'évaluation"
message={`Supprimer l'évaluation de ${evaluation.candidateName} ? Cette action est irréversible.`}
confirmLabel="Supprimer"
cancelLabel="Annuler"
variant="danger"
onConfirm={async () => {
const res = await fetch(`/api/evaluations/${id}`, { method: "DELETE" });
if (res.ok) router.push("/");
else alert("Erreur lors de la suppression");
}}
onCancel={() => setDeleteConfirmOpen(false)}
/>
</div>
);
}

View File

@@ -0,0 +1,91 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { CandidateForm } from "@/components/CandidateForm";
export default function NewEvaluationPage() {
const router = useRouter();
const [templates, setTemplates] = useState<{ id: string; name: string }[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [form, setForm] = useState({
candidateName: "",
candidateRole: "",
evaluatorName: "",
evaluationDate: new Date().toISOString().split("T")[0],
templateId: "",
});
useEffect(() => {
fetch("/api/templates")
.then((r) => r.json())
.then((data) => {
setTemplates(data);
if (data.length > 0 && !form.templateId) {
setForm((f) => ({ ...f, templateId: data[0].id }));
}
})
.finally(() => setLoading(false));
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!form.templateId) return;
setSaving(true);
try {
const res = await fetch("/api/evaluations", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...form,
evaluationDate: new Date(form.evaluationDate).toISOString(),
}),
});
const data = await res.json();
if (res.ok) {
router.push(`/evaluations/${data.id}`);
} else {
alert(data.error ?? "Erreur");
}
} finally {
setSaving(false);
}
};
if (loading) {
return <div className="py-12 text-center font-mono text-xs text-zinc-600 dark:text-zinc-500">loading templates...</div>;
}
return (
<div>
<h1 className="mb-6 font-mono text-lg font-medium text-zinc-800 dark:text-zinc-200">Nouvelle évaluation</h1>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="rounded-lg border border-zinc-200 dark:border-zinc-600 bg-white dark:bg-zinc-800 p-4 shadow-sm dark:shadow-none">
<h2 className="mb-3 font-mono text-xs text-zinc-600 dark:text-zinc-500">Session</h2>
<CandidateForm
{...form}
templates={templates}
onChange={(field, value) => setForm((f) => ({ ...f, [field]: value }))}
/>
</div>
<div className="flex gap-2">
<button
type="submit"
disabled={saving || !form.templateId}
className="rounded border border-cyan-500/50 bg-cyan-500/20 px-4 py-2 font-mono text-sm text-cyan-600 dark:text-cyan-400 hover:bg-cyan-500/30 disabled:opacity-50"
>
{saving ? "..." : "créer →"}
</button>
<button
type="button"
onClick={() => router.back()}
className="rounded border border-zinc-300 dark:border-zinc-700 px-4 py-2 font-mono text-sm text-zinc-600 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800"
>
annuler
</button>
</div>
</form>
</div>
);
}

View File

@@ -1,26 +1,27 @@
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
:root {
--background: #ffffff;
--foreground: #171717;
--accent: #0891b2;
--accent-hover: #0e7490;
}
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
body {
font-family: var(--font-sans), system-ui, sans-serif;
}
body {
background: var(--background);
color: var(--foreground);
font-family: Arial, Helvetica, sans-serif;
/* Tech input styling */
input, select, textarea {
font-family: inherit;
}
input:focus, select:focus, textarea:focus {
outline: none;
ring: 2px;
}

View File

@@ -1,5 +1,7 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { Header } from "@/components/Header";
import { ThemeProvider } from "@/components/ThemeProvider";
import "./globals.css";
const geistSans = Geist({
@@ -13,8 +15,8 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "Évaluateur Maturité IA Gen",
description: "Équipe Cars Front - Outil d'évaluation de la maturité IA Gen",
};
export default function RootLayout({
@@ -23,11 +25,12 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
<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`}>
<ThemeProvider>
<Header />
<main className="mx-auto max-w-5xl px-4 py-6">{children}</main>
</ThemeProvider>
</body>
</html>
);

View File

@@ -1,65 +1,137 @@
import Image from "next/image";
"use client";
import { useState, useEffect } from "react";
import Link from "next/link";
import { format } from "date-fns";
import { ConfirmModal } from "@/components/ConfirmModal";
interface EvalRow {
id: string;
candidateName: string;
candidateRole: string;
evaluatorName: string;
evaluationDate: string;
template?: { name: string };
status: string;
}
export default function DashboardPage() {
const [evaluations, setEvaluations] = useState<EvalRow[]>([]);
const [loading, setLoading] = useState(true);
const [deleteTarget, setDeleteTarget] = useState<EvalRow | null>(null);
useEffect(() => {
fetch("/api/evaluations")
.then((r) => r.json())
.then(setEvaluations)
.catch(() => [])
.finally(() => setLoading(false));
}, []);
export default function Home() {
return (
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
<div>
<div className="mb-6 flex items-center justify-between">
<h1 className="font-mono text-lg font-medium text-zinc-800 dark:text-zinc-100">Évaluations</h1>
<Link
href="/evaluations/new"
className="rounded border border-cyan-500/50 bg-cyan-500/10 px-3 py-1.5 font-mono text-xs text-cyan-600 dark:text-cyan-400 hover:bg-cyan-500/20 transition-colors"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
+ nouvelle
</Link>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
<div className="overflow-hidden rounded-lg border border-zinc-200 dark:border-zinc-600 bg-white dark:bg-zinc-800 shadow-sm dark:shadow-none">
<table className="min-w-full">
<thead>
<tr className="border-b border-zinc-200 dark:border-zinc-600 bg-zinc-50 dark:bg-zinc-700/80">
<th className="px-4 py-2.5 text-left font-mono text-xs text-zinc-600 dark:text-zinc-400">Candidat</th>
<th className="px-4 py-2.5 text-left font-mono text-xs text-zinc-600 dark:text-zinc-400">Rôle</th>
<th className="px-4 py-2.5 text-left font-mono text-xs text-zinc-600 dark:text-zinc-400">Évaluateur</th>
<th className="px-4 py-2.5 text-left font-mono text-xs text-zinc-600 dark:text-zinc-400">Date</th>
<th className="px-4 py-2.5 text-left font-mono text-xs text-zinc-600 dark:text-zinc-400">Modèle</th>
<th className="px-4 py-2.5 text-left font-mono text-xs text-zinc-600 dark:text-zinc-400">Statut</th>
<th className="px-4 py-2.5 text-right font-mono text-xs text-zinc-600 dark:text-zinc-400"></th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td colSpan={7} className="px-4 py-12 text-center font-mono text-xs text-zinc-600 dark:text-zinc-500">
loading...
</td>
</tr>
) : evaluations.length === 0 ? (
<tr>
<td colSpan={7} className="px-4 py-12 text-center text-zinc-600 dark:text-zinc-500">
Aucune évaluation.{" "}
<Link href="/evaluations/new" className="text-cyan-600 dark:text-cyan-400 hover:underline">
Créer
</Link>
</td>
</tr>
) : (
evaluations.map((e) => (
<tr key={e.id} className="border-b border-zinc-200 dark:border-zinc-600/50 hover:bg-zinc-50 dark:hover:bg-zinc-700/50 transition-colors">
<td className="px-4 py-2.5 text-sm font-medium text-zinc-800 dark:text-zinc-100">{e.candidateName}</td>
<td className="px-4 py-2.5 text-sm text-zinc-600 dark:text-zinc-400">{e.candidateRole}</td>
<td className="px-4 py-2.5 text-sm text-zinc-600 dark:text-zinc-400">{e.evaluatorName}</td>
<td className="px-4 py-2.5 font-mono text-xs text-zinc-600 dark:text-zinc-400">
{format(new Date(e.evaluationDate), "yyyy-MM-dd")}
</td>
<td className="px-4 py-2.5 text-sm text-zinc-600 dark:text-zinc-400">{e.template?.name ?? ""}</td>
<td className="px-4 py-2.5">
<span
className={`font-mono text-xs px-1.5 py-0.5 rounded ${
e.status === "submitted" ? "bg-emerald-500/20 text-emerald-600 dark:text-emerald-400" : "bg-amber-500/20 text-amber-600 dark:text-amber-400"
}`}
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
{e.status === "submitted" ? "ok" : "draft"}
</span>
</td>
<td className="px-4 py-2.5 text-right">
<span className="inline-flex items-center gap-3">
<Link
href={`/evaluations/${e.id}`}
className="font-mono text-xs text-cyan-600 dark:text-cyan-400 hover:text-cyan-500 dark:hover:text-cyan-300"
>
Documentation
</a>
</Link>
<button
type="button"
onClick={() => setDeleteTarget(e)}
className="font-mono text-xs text-red-500 hover:text-red-400"
title="Supprimer"
>
×
</button>
</span>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</main>
<ConfirmModal
isOpen={!!deleteTarget}
title="Supprimer l'évaluation"
message={
deleteTarget
? `Supprimer l'évaluation de ${deleteTarget.candidateName} ? Cette action est irréversible.`
: ""
}
confirmLabel="Supprimer"
cancelLabel="Annuler"
variant="danger"
onConfirm={async () => {
if (!deleteTarget) return;
const res = await fetch(`/api/evaluations/${deleteTarget.id}`, { method: "DELETE" });
if (res.ok) setEvaluations((prev) => prev.filter((x) => x.id !== deleteTarget.id));
else alert("Erreur lors de la suppression");
}}
onCancel={() => setDeleteTarget(null)}
/>
</div>
);
}

View File

@@ -0,0 +1,94 @@
"use client";
interface CandidateFormProps {
candidateName: string;
candidateRole: string;
evaluatorName: string;
evaluationDate: string;
templateId: string;
templates: { id: string; name: string }[];
onChange: (field: string, value: string) => void;
disabled?: boolean;
templateDisabled?: boolean;
}
const inputClass =
"w-full rounded border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-700/80 px-3 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/50 transition-colors";
const labelClass = "mb-0.5 block text-xs font-medium text-zinc-600 dark:text-zinc-400";
export function CandidateForm({
candidateName,
candidateRole,
evaluatorName,
evaluationDate,
templateId,
templates,
onChange,
disabled,
templateDisabled,
}: CandidateFormProps) {
return (
<div className="flex flex-wrap items-end gap-x-6 gap-y-3">
<div className="min-w-[140px]">
<label className={labelClass}>Candidat</label>
<input
type="text"
value={candidateName}
onChange={(e) => onChange("candidateName", e.target.value)}
className={inputClass}
disabled={disabled}
placeholder="Alice Chen"
/>
</div>
<div className="min-w-[140px]">
<label className={labelClass}>Rôle</label>
<input
type="text"
value={candidateRole}
onChange={(e) => onChange("candidateRole", e.target.value)}
className={inputClass}
disabled={disabled}
placeholder="ML Engineer"
/>
</div>
<div className="min-w-[120px]">
<label className={labelClass}>Évaluateur</label>
<input
type="text"
value={evaluatorName}
onChange={(e) => onChange("evaluatorName", e.target.value)}
className={inputClass}
disabled={disabled}
placeholder="Jean D."
/>
</div>
<div className="min-w-[120px]">
<label className={labelClass}>Date</label>
<input
type="date"
value={evaluationDate}
onChange={(e) => onChange("evaluationDate", e.target.value)}
className={inputClass}
disabled={disabled}
/>
</div>
<div className="min-w-[160px]">
<label className={labelClass}>Modèle</label>
<select
value={templateId}
onChange={(e) => onChange("templateId", e.target.value)}
className={inputClass}
disabled={disabled || templateDisabled}
>
<option value=""></option>
{templates.map((t) => (
<option key={t.id} value={t.id}>
{t.name}
</option>
))}
</select>
</div>
</div>
);
}

View File

@@ -0,0 +1,64 @@
"use client";
interface ConfirmModalProps {
isOpen: boolean;
title: string;
message: string;
confirmLabel?: string;
cancelLabel?: string;
variant?: "danger" | "default";
onConfirm: () => void | Promise<void>;
onCancel: () => void;
}
export function ConfirmModal({
isOpen,
title,
message,
confirmLabel = "Confirmer",
cancelLabel = "Annuler",
variant = "default",
onConfirm,
onCancel,
}: ConfirmModalProps) {
if (!isOpen) return null;
const handleConfirm = async () => {
await onConfirm();
onCancel();
};
return (
<>
<div className="fixed inset-0 z-40 bg-black/60" onClick={onCancel} aria-hidden="true" />
<div
className="fixed left-1/2 top-1/2 z-50 w-full max-w-sm -translate-x-1/2 -translate-y-1/2 rounded-lg border border-zinc-200 dark:border-zinc-600 bg-white dark:bg-zinc-800 p-4 shadow-xl"
role="dialog"
aria-label={title}
>
<h3 className="mb-2 font-mono text-sm font-medium text-zinc-800 dark:text-zinc-100">{title}</h3>
<p className="mb-4 text-sm text-zinc-600 dark:text-zinc-400">{message}</p>
<div className="flex gap-3 justify-end">
<button
type="button"
onClick={onCancel}
className="rounded border border-zinc-300 dark:border-zinc-600 px-3 py-1.5 font-mono text-xs text-zinc-600 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-700"
>
{cancelLabel}
</button>
<button
type="button"
onClick={handleConfirm}
className={`rounded px-3 py-1.5 font-mono text-xs ${
variant === "danger"
? "border border-red-500/50 bg-red-500/20 text-red-600 dark:text-red-400 hover:bg-red-500/30"
: "border border-cyan-500/50 bg-cyan-500/20 text-cyan-600 dark:text-cyan-400 hover:bg-cyan-500/30"
}`}
>
{confirmLabel}
</button>
</div>
</div>
</>
);
}

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>
);
}

View File

@@ -0,0 +1,51 @@
"use client";
interface ExportModalProps {
isOpen: boolean;
onClose: () => void;
evaluationId: string;
}
export function ExportModal({ isOpen, onClose, evaluationId }: ExportModalProps) {
if (!isOpen) return null;
const base = typeof window !== "undefined" ? window.location.origin : "";
const csvUrl = `${base}/api/export/csv?id=${evaluationId}`;
const pdfUrl = `${base}/api/export/pdf?id=${evaluationId}`;
return (
<>
<div className="fixed inset-0 z-40 bg-black/60" onClick={onClose} aria-hidden="true" />
<div
className="fixed left-1/2 top-1/2 z-50 w-full max-w-sm -translate-x-1/2 -translate-y-1/2 rounded-lg border border-zinc-200 dark:border-zinc-600 bg-white dark:bg-zinc-800 p-4 shadow-xl"
role="dialog"
aria-label="Export"
>
<h3 className="mb-4 font-mono text-sm font-medium text-zinc-800 dark:text-zinc-200">Export</h3>
<div className="flex flex-col gap-2">
<a
href={csvUrl}
download
className="rounded border border-zinc-300 dark:border-zinc-600 bg-zinc-100 dark:bg-zinc-700 px-4 py-2 text-center font-mono text-xs text-zinc-700 dark:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-zinc-700"
>
csv
</a>
<a
href={pdfUrl}
download
className="rounded border border-zinc-300 dark:border-zinc-600 bg-zinc-100 dark:bg-zinc-700 px-4 py-2 text-center font-mono text-xs text-zinc-700 dark:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-zinc-700"
>
pdf
</a>
</div>
<button
type="button"
onClick={onClose}
className="mt-4 w-full rounded border border-zinc-300 dark:border-zinc-700 py-2 font-mono text-xs text-zinc-600 dark:text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800"
>
fermer
</button>
</div>
</>
);
}

28
src/components/Header.tsx Normal file
View File

@@ -0,0 +1,28 @@
"use client";
import Link from "next/link";
import { ThemeToggle } from "./ThemeToggle";
export function Header() {
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">
<div className="mx-auto flex h-12 max-w-6xl items-center justify-between px-4">
<Link href="/" className="font-mono text-sm font-medium text-zinc-900 dark:text-zinc-50 tracking-tight">
iag-eval
</Link>
<nav className="flex items-center gap-6 font-mono text-xs">
<Link href="/" className="text-zinc-500 hover:text-cyan-600 dark:text-zinc-400 dark:hover:text-cyan-400 transition-colors">
/dashboard
</Link>
<Link href="/evaluations/new" className="text-zinc-500 hover:text-cyan-600 dark:text-zinc-400 dark:hover:text-cyan-400 transition-colors">
/new
</Link>
<Link href="/admin" className="text-zinc-500 hover:text-cyan-600 dark:text-zinc-400 dark:hover:text-cyan-400 transition-colors">
/admin
</Link>
<ThemeToggle />
</nav>
</div>
</header>
);
}

View File

@@ -0,0 +1,76 @@
"use client";
import {
Radar,
RadarChart as RechartsRadar,
PolarGrid,
PolarAngleAxis,
PolarRadiusAxis,
ResponsiveContainer,
Legend,
Tooltip,
} from "recharts";
import { useTheme } from "./ThemeProvider";
interface DataPoint {
dimension: string;
score: number;
fullMark: number;
}
interface RadarChartProps {
data: DataPoint[];
}
const LIGHT = {
grid: "#d4d4d8",
tick: "#71717a",
axis: "#a1a1aa",
tooltipBg: "#fafafa",
tooltipBorder: "#e4e4e7",
tooltipText: "#18181b",
};
const DARK = {
grid: "#71717a",
tick: "#a1a1aa",
axis: "#d4d4d8",
tooltipBg: "#27272a",
tooltipBorder: "#52525b",
tooltipText: "#fafafa",
};
export function RadarChart({ data }: RadarChartProps) {
const { theme } = useTheme();
const c = theme === "dark" ? DARK : LIGHT;
if (data.length === 0) return null;
return (
<div className="h-72 w-full">
<ResponsiveContainer width="100%" height="100%">
<RechartsRadar data={data}>
<PolarGrid stroke={c.grid} />
<PolarAngleAxis dataKey="dimension" tick={{ fontSize: 9, fill: c.axis }} />
<PolarRadiusAxis angle={30} domain={[0, 5]} tick={{ fill: c.tick }} />
<Radar
name="Score"
dataKey="score"
stroke="#0891b2"
fill="#0891b2"
fillOpacity={0.2}
/>
<Tooltip
contentStyle={{
backgroundColor: c.tooltipBg,
border: `1px solid ${c.tooltipBorder}`,
borderRadius: "4px",
color: c.tooltipText,
fontSize: "12px",
}}
/>
<Legend wrapperStyle={{ fontSize: "11px" }} />
</RechartsRadar>
</ResponsiveContainer>
</div>
);
}

View File

@@ -0,0 +1,40 @@
"use client";
import { createContext, useContext, useEffect, useState } from "react";
type Theme = "light" | "dark";
const ThemeContext = createContext<{ theme: Theme; setTheme: (t: Theme) => void } | null>(null);
export function useTheme() {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error("useTheme must be used within ThemeProvider");
return ctx;
}
export function ThemeProvider({ children }: { children: React.ReactNode }) {
const [theme, setThemeState] = useState<Theme>("dark");
const [mounted, setMounted] = useState(false);
useEffect(() => {
const stored = localStorage.getItem("theme") as Theme | null;
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
const initial = stored ?? (prefersDark ? "dark" : "light");
setThemeState(initial);
setMounted(true);
}, []);
useEffect(() => {
if (!mounted) return;
document.documentElement.classList.toggle("dark", theme === "dark");
localStorage.setItem("theme", theme);
}, [theme, mounted]);
const setTheme = (t: Theme) => setThemeState(t);
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}

View File

@@ -0,0 +1,18 @@
"use client";
import { useTheme } from "./ThemeProvider";
export function ThemeToggle() {
const { theme, setTheme } = useTheme();
return (
<button
type="button"
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
className="font-mono text-xs text-zinc-600 dark:text-zinc-500 hover:text-zinc-800 dark:hover:text-zinc-300 transition-colors"
title={theme === "dark" ? "Passer au thème clair" : "Passer au thème sombre"}
>
{theme === "dark" ? "☀" : "☽"}
</button>
);
}

7
src/lib/db.ts Normal file
View File

@@ -0,0 +1,7 @@
import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
export const prisma = globalForPrisma.prisma ?? new PrismaClient();
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;

View File

@@ -0,0 +1,91 @@
import { describe, it, expect } from "vitest";
import {
computeAverageScore,
generateFindings,
generateRecommendations,
evaluationToCsvRows,
} from "./export-utils";
describe("computeAverageScore", () => {
it("returns 0 for empty array", () => {
expect(computeAverageScore([])).toBe(0);
});
it("returns 0 when all scores are null", () => {
expect(computeAverageScore([{ score: null }, { score: null }])).toBe(0);
});
it("computes average of valid scores", () => {
expect(computeAverageScore([{ score: 2 }, { score: 4 }])).toBe(3);
});
it("ignores null and out-of-range scores", () => {
expect(computeAverageScore([{ score: 2 }, { score: null }, { score: 4 }, { score: 0 }])).toBe(3);
});
});
describe("generateFindings", () => {
const mkDim = (name: string) => ({ name });
const mkScore = (score: number, name: string) => ({ score, dimension: mkDim(name) });
it("returns message when no scores", () => {
expect(generateFindings([])).toContain("Aucun score");
});
it("includes average and strengths", () => {
const scores = [mkScore(4, "A"), mkScore(5, "B")];
const out = generateFindings(scores);
expect(out).toContain("4.5/5");
expect(out).toContain("A");
expect(out).toContain("B");
});
it("includes weak areas", () => {
const scores = [mkScore(1, "Weak"), mkScore(4, "Strong")];
const out = generateFindings(scores);
expect(out).toContain("Weak");
expect(out).toContain("Strong");
});
});
describe("generateRecommendations", () => {
const mkDim = (name: string) => ({ name });
it("returns generic when no weak scores", () => {
const scores = [{ score: 4, dimension: mkDim("A") }];
expect(generateRecommendations(scores)).toContain("pratiques");
});
it("mentions weak dimensions", () => {
const scores = [{ score: 1, dimension: mkDim("Gap") }];
expect(generateRecommendations(scores)).toContain("Gap");
});
});
describe("evaluationToCsvRows", () => {
it("produces header + one row per dimension", () => {
const evalData = {
candidateName: "Alice",
candidateRole: "Engineer",
evaluatorName: "Bob",
evaluationDate: new Date("2025-02-15"),
status: "draft",
dimensionScores: [
{
dimension: { name: "Data Quality" },
score: 3,
justification: "Good",
examplesObserved: "x",
confidence: "high",
},
],
} as Parameters<typeof evaluationToCsvRows>[0];
const rows = evaluationToCsvRows(evalData);
expect(rows[0]).toContain("candidateName");
expect(rows.length).toBe(2);
expect(rows[1]).toContain("Alice");
expect(rows[1]).toContain("Data Quality");
expect(rows[1]).toContain("3");
});
});

81
src/lib/export-utils.ts Normal file
View File

@@ -0,0 +1,81 @@
import type { Evaluation, DimensionScore, TemplateDimension, Template } from "@prisma/client";
export interface EvaluationWithScores extends Evaluation {
template?: Template | null;
dimensionScores: (DimensionScore & { dimension: TemplateDimension })[];
}
/** Compute average score across dimensions (1-5 scale) */
export function computeAverageScore(scores: { score: number | null }[]): number {
const valid = scores.filter((s) => s.score != null && s.score >= 1 && s.score <= 5);
if (valid.length === 0) return 0;
return valid.reduce((a, s) => a + (s.score ?? 0), 0) / valid.length;
}
/** Generate findings paragraph from scores (deterministic template) */
export function generateFindings(
scores: { score: number | null; dimension: { name?: string; title?: string } }[]
): string {
const withScore = scores.filter((s) => s.score != null);
if (withScore.length === 0) return "Aucun score enregistré pour l'instant.";
const avg = computeAverageScore(withScore);
const dimName = (d: { name?: string; title?: string }) => d.title ?? d.name ?? "";
const weak = withScore.filter((s) => (s.score ?? 0) <= 2).map((s) => dimName(s.dimension));
const strong = withScore.filter((s) => (s.score ?? 0) >= 4).map((s) => dimName(s.dimension));
let text = `Score de maturité global : ${avg.toFixed(1)}/5. `;
if (strong.length > 0) {
text += `Points forts : ${strong.join(", ")}. `;
}
if (weak.length > 0) {
text += `Axes d'amélioration : ${weak.join(", ")}.`;
} else if (strong.length === 0) {
text += "Les scores indiquent une maturité modérée sur les dimensions.";
}
return text;
}
/** Generate recommendations (deterministic template) */
export function generateRecommendations(
scores: { score: number | null; dimension: { name?: string; title?: string } }[]
): string {
const dimName = (d: { name?: string; title?: string }) => d.title ?? d.name ?? "";
const weak = scores.filter((s) => (s.score ?? 5) <= 2).map((s) => dimName(s.dimension));
if (weak.length === 0) return "Continuer à s'appuyer sur les pratiques actuelles. Envisager le mentorat pour les juniors.";
return `Prioriser la formation et le développement pour : ${weak.join(", ")}. Envisager des programmes de montée en compétence structurés et une expertise externe si les écarts sont importants.`;
}
/** Convert evaluation to CSV rows (one per dimension + metadata row) */
export function evaluationToCsvRows(evalData: EvaluationWithScores): string[][] {
const rows: string[][] = [];
rows.push([
"candidateName",
"candidateRole",
"evaluatorName",
"evaluationDate",
"template",
"status",
"dimension",
"score",
"justification",
"examplesObserved",
"confidence",
]);
for (const ds of evalData.dimensionScores) {
rows.push([
evalData.candidateName,
evalData.candidateRole,
evalData.evaluatorName,
evalData.evaluationDate.toISOString().split("T")[0],
evalData.template?.name ?? "",
evalData.status,
(ds.dimension as { name?: string; title?: string }).title ?? (ds.dimension as { name?: string }).name ?? "",
String(ds.score ?? ""),
ds.justification ?? "",
ds.examplesObserved ?? "",
ds.confidence ?? "",
]);
}
return rows;
}

View File

@@ -0,0 +1,21 @@
import { test, expect } from "@playwright/test";
/**
* E2E test skeleton - requires: pnpm db:push && pnpm db:seed && pnpm dev
*/
test.describe("Dashboard", () => {
test("loads and shows evaluations list", async ({ page }) => {
await page.goto("/");
await expect(page.locator("h1")).toContainText("Evaluations");
// Either empty state or table
const table = page.locator("table");
await expect(table).toBeVisible();
});
test("navigates to new evaluation", async ({ page }) => {
await page.goto("/");
await page.click('a[href="/evaluations/new"]');
await expect(page).toHaveURL(/\/evaluations\/new/);
await expect(page.locator("h1")).toContainText("New Evaluation");
});
});

16
vitest.config.ts Normal file
View File

@@ -0,0 +1,16 @@
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import path from "path";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
include: ["src/**/*.test.ts", "src/**/*.test.tsx"],
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
});