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:
148
README.md
148
README.md
@@ -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
|
```bash
|
||||||
npm run dev
|
pnpm install
|
||||||
# or
|
cp .env.example .env
|
||||||
yarn dev
|
pnpm db:generate
|
||||||
# or
|
pnpm db:push
|
||||||
pnpm dev
|
pnpm db:seed
|
||||||
# or
|
|
||||||
bun dev
|
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
## API Routes
|
||||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
|
||||||
|
|
||||||
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 2–3 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: 1–5, 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
|
||||||
|
```
|
||||||
|
|||||||
28
package.json
28
package.json
@@ -2,25 +2,45 @@
|
|||||||
"name": "iag-dev-evaluator",
|
"name": "iag-dev-evaluator",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"prisma": {
|
||||||
|
"seed": "tsx prisma/seed.ts"
|
||||||
|
},
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"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": {
|
"dependencies": {
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"jspdf": "^4.2.0",
|
||||||
|
"jspdf-autotable": "^5.0.7",
|
||||||
"next": "16.1.6",
|
"next": "16.1.6",
|
||||||
"react": "19.2.3",
|
"react": "19.2.3",
|
||||||
"react-dom": "19.2.3"
|
"react-dom": "19.2.3",
|
||||||
|
"recharts": "^3.7.0",
|
||||||
|
"@prisma/client": "^5.22.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.58.2",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
"@vitejs/plugin-react": "^5.1.4",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "16.1.6",
|
"eslint-config-next": "16.1.6",
|
||||||
|
"jsdom": "^28.1.0",
|
||||||
|
"prisma": "^5.22.0",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"typescript": "^5"
|
"tsx": "^4.21.0",
|
||||||
|
"typescript": "^5",
|
||||||
|
"vitest": "^4.0.18"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
20
playwright.config.ts
Normal file
20
playwright.config.ts
Normal 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
1881
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
BIN
prisma/dev.db
Normal file
BIN
prisma/dev.db
Normal file
Binary file not shown.
89
prisma/schema.prisma
Normal file
89
prisma/schema.prisma
Normal 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
222
prisma/seed.ts
Normal 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
66
src/app/admin/page.tsx
Normal 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
38
src/app/api/auth/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
176
src/app/api/evaluations/[id]/route.ts
Normal file
176
src/app/api/evaluations/[id]/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
87
src/app/api/evaluations/route.ts
Normal file
87
src/app/api/evaluations/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/app/api/export/csv/route.ts
Normal file
38
src/app/api/export/csv/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
68
src/app/api/export/pdf/route.ts
Normal file
68
src/app/api/export/pdf/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
29
src/app/api/templates/data/route.ts
Normal file
29
src/app/api/templates/data/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
29
src/app/api/templates/route.ts
Normal file
29
src/app/api/templates/route.ts
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
361
src/app/evaluations/[id]/page.tsx
Normal file
361
src/app/evaluations/[id]/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
91
src/app/evaluations/new/page.tsx
Normal file
91
src/app/evaluations/new/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,26 +1,27 @@
|
|||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@custom-variant dark (&:where(.dark, .dark *));
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: #ffffff;
|
--accent: #0891b2;
|
||||||
--foreground: #171717;
|
--accent-hover: #0e7490;
|
||||||
}
|
}
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
--color-background: var(--background);
|
|
||||||
--color-foreground: var(--foreground);
|
|
||||||
--font-sans: var(--font-geist-sans);
|
--font-sans: var(--font-geist-sans);
|
||||||
--font-mono: var(--font-geist-mono);
|
--font-mono: var(--font-geist-mono);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
body {
|
||||||
:root {
|
font-family: var(--font-sans), system-ui, sans-serif;
|
||||||
--background: #0a0a0a;
|
|
||||||
--foreground: #ededed;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
/* Tech input styling */
|
||||||
background: var(--background);
|
input, select, textarea {
|
||||||
color: var(--foreground);
|
font-family: inherit;
|
||||||
font-family: Arial, Helvetica, sans-serif;
|
}
|
||||||
|
|
||||||
|
input:focus, select:focus, textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
ring: 2px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
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 { Header } from "@/components/Header";
|
||||||
|
import { ThemeProvider } from "@/components/ThemeProvider";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|
||||||
const geistSans = Geist({
|
const geistSans = Geist({
|
||||||
@@ -13,8 +15,8 @@ const geistMono = Geist_Mono({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Create Next App",
|
title: "Évaluateur Maturité IA Gen",
|
||||||
description: "Generated by create next app",
|
description: "Équipe Cars Front - Outil d'évaluation de la maturité IA Gen",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@@ -23,11 +25,12 @@ export default function RootLayout({
|
|||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}>) {
|
}>) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="fr" suppressHydrationWarning>
|
||||||
<body
|
<body className={`${geistSans.variable} ${geistMono.variable} min-h-screen bg-zinc-100 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-50 antialiased`}>
|
||||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
<ThemeProvider>
|
||||||
>
|
<Header />
|
||||||
{children}
|
<main className="mx-auto max-w-5xl px-4 py-6">{children}</main>
|
||||||
|
</ThemeProvider>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
192
src/app/page.tsx
192
src/app/page.tsx
@@ -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 (
|
return (
|
||||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
<div>
|
||||||
<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">
|
<div className="mb-6 flex items-center justify-between">
|
||||||
<Image
|
<h1 className="font-mono text-lg font-medium text-zinc-800 dark:text-zinc-100">Évaluations</h1>
|
||||||
className="dark:invert"
|
<Link
|
||||||
src="/next.svg"
|
href="/evaluations/new"
|
||||||
alt="Next.js logo"
|
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"
|
||||||
width={100}
|
>
|
||||||
height={20}
|
+ nouvelle
|
||||||
priority
|
</Link>
|
||||||
/>
|
</div>
|
||||||
<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">
|
<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">
|
||||||
To get started, edit the page.tsx file.
|
<table className="min-w-full">
|
||||||
</h1>
|
<thead>
|
||||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
<tr className="border-b border-zinc-200 dark:border-zinc-600 bg-zinc-50 dark:bg-zinc-700/80">
|
||||||
Looking for a starting point or more instructions? Head over to{" "}
|
<th className="px-4 py-2.5 text-left font-mono text-xs text-zinc-600 dark:text-zinc-400">Candidat</th>
|
||||||
<a
|
<th className="px-4 py-2.5 text-left font-mono text-xs text-zinc-600 dark:text-zinc-400">Rôle</th>
|
||||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
<th className="px-4 py-2.5 text-left font-mono text-xs text-zinc-600 dark:text-zinc-400">Évaluateur</th>
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
<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>
|
||||||
Templates
|
<th className="px-4 py-2.5 text-left font-mono text-xs text-zinc-600 dark:text-zinc-400">Statut</th>
|
||||||
</a>{" "}
|
<th className="px-4 py-2.5 text-right font-mono text-xs text-zinc-600 dark:text-zinc-400">—</th>
|
||||||
or the{" "}
|
</tr>
|
||||||
<a
|
</thead>
|
||||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
<tbody>
|
||||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
{loading ? (
|
||||||
>
|
<tr>
|
||||||
Learning
|
<td colSpan={7} className="px-4 py-12 text-center font-mono text-xs text-zinc-600 dark:text-zinc-500">
|
||||||
</a>{" "}
|
loading...
|
||||||
center.
|
</td>
|
||||||
</p>
|
</tr>
|
||||||
</div>
|
) : evaluations.length === 0 ? (
|
||||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
<tr>
|
||||||
<a
|
<td colSpan={7} className="px-4 py-12 text-center text-zinc-600 dark:text-zinc-500">
|
||||||
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]"
|
Aucune évaluation.{" "}
|
||||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
<Link href="/evaluations/new" className="text-cyan-600 dark:text-cyan-400 hover:underline">
|
||||||
target="_blank"
|
Créer
|
||||||
rel="noopener noreferrer"
|
</Link>
|
||||||
>
|
</td>
|
||||||
<Image
|
</tr>
|
||||||
className="dark:invert"
|
) : (
|
||||||
src="/vercel.svg"
|
evaluations.map((e) => (
|
||||||
alt="Vercel logomark"
|
<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">
|
||||||
width={16}
|
<td className="px-4 py-2.5 text-sm font-medium text-zinc-800 dark:text-zinc-100">{e.candidateName}</td>
|
||||||
height={16}
|
<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>
|
||||||
Deploy Now
|
<td className="px-4 py-2.5 font-mono text-xs text-zinc-600 dark:text-zinc-400">
|
||||||
</a>
|
{format(new Date(e.evaluationDate), "yyyy-MM-dd")}
|
||||||
<a
|
</td>
|
||||||
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]"
|
<td className="px-4 py-2.5 text-sm text-zinc-600 dark:text-zinc-400">{e.template?.name ?? ""}</td>
|
||||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
<td className="px-4 py-2.5">
|
||||||
target="_blank"
|
<span
|
||||||
rel="noopener noreferrer"
|
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"
|
||||||
Documentation
|
}`}
|
||||||
</a>
|
>
|
||||||
</div>
|
{e.status === "submitted" ? "ok" : "draft"}
|
||||||
</main>
|
</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"
|
||||||
|
>
|
||||||
|
→
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
94
src/components/CandidateForm.tsx
Normal file
94
src/components/CandidateForm.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
64
src/components/ConfirmModal.tsx
Normal file
64
src/components/ConfirmModal.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
190
src/components/DimensionCard.tsx
Normal file
190
src/components/DimensionCard.tsx
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
interface Dimension {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
rubric: string;
|
||||||
|
suggestedQuestions?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DimensionScore {
|
||||||
|
score: number | null;
|
||||||
|
justification: string | null;
|
||||||
|
examplesObserved: string | null;
|
||||||
|
confidence: string | null;
|
||||||
|
candidateNotes: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DimensionCardProps {
|
||||||
|
dimension: Dimension;
|
||||||
|
score: DimensionScore | null;
|
||||||
|
index: number;
|
||||||
|
onScoreChange: (dimensionId: string, data: Partial<DimensionScore>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRubric(rubric: string): string[] {
|
||||||
|
if (rubric === "1-5" || !rubric) return ["1", "2", "3", "4", "5"];
|
||||||
|
const labels: string[] = [];
|
||||||
|
for (let i = 1; i <= 5; i++) {
|
||||||
|
const m = rubric.match(new RegExp(`${i}:([^;]+)`));
|
||||||
|
labels.push(m ? m[1].trim() : String(i));
|
||||||
|
}
|
||||||
|
return labels;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseQuestions(s: string | null | undefined): string[] {
|
||||||
|
if (!s) return [];
|
||||||
|
try {
|
||||||
|
const arr = JSON.parse(s) as unknown;
|
||||||
|
return Array.isArray(arr) ? arr.filter((x): x is string => typeof x === "string") : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const inputClass =
|
||||||
|
"w-full rounded border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-700/80 px-2.5 py-1.5 text-sm text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 dark:placeholder-zinc-500 focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500/30";
|
||||||
|
|
||||||
|
export function DimensionCard({ dimension, score, index, onScoreChange }: DimensionCardProps) {
|
||||||
|
const [notes, setNotes] = useState(score?.candidateNotes ?? "");
|
||||||
|
const hasQuestions = parseQuestions(dimension.suggestedQuestions).length > 0;
|
||||||
|
const [expanded, setExpanded] = useState(hasQuestions);
|
||||||
|
const currentScore = score?.score ?? null;
|
||||||
|
const rubricLabels = parseRubric(dimension.rubric);
|
||||||
|
const questions = parseQuestions(dimension.suggestedQuestions);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-lg border border-zinc-200 dark:border-zinc-600 bg-white dark:bg-zinc-800 shadow-sm dark:shadow-none overflow-hidden">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setExpanded((e) => !e)}
|
||||||
|
className="flex w-full items-center justify-between gap-4 px-4 py-3 text-left hover:bg-zinc-50 dark:hover:bg-zinc-700/50 transition-colors"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 min-w-0">
|
||||||
|
<span className="font-mono text-xs text-zinc-500 tabular-nums w-5">{index + 1}.</span>
|
||||||
|
<span className="font-medium text-zinc-800 dark:text-zinc-100 truncate">{dimension.title}</span>
|
||||||
|
{currentScore != null && (
|
||||||
|
<span
|
||||||
|
className={`shrink-0 font-mono text-xs px-1.5 py-0.5 rounded ${
|
||||||
|
currentScore <= 2 ? "bg-amber-500/20 text-amber-600 dark:text-amber-400" :
|
||||||
|
currentScore >= 4 ? "bg-emerald-500/20 text-emerald-600 dark:text-emerald-400" :
|
||||||
|
"bg-zinc-300 dark:bg-zinc-700 text-zinc-700 dark:text-zinc-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{currentScore}/5
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-zinc-500 text-sm">{expanded ? "−" : "+"}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{expanded && (
|
||||||
|
<div className="border-t border-zinc-200 dark:border-zinc-600 px-4 py-3 space-y-3">
|
||||||
|
{/* Questions suggérées - en premier */}
|
||||||
|
{questions.length > 0 && (
|
||||||
|
<div className="rounded bg-zinc-50 dark:bg-zinc-700/80 p-2.5">
|
||||||
|
<p className="mb-2 text-xs font-medium text-zinc-500">Questions suggérées</p>
|
||||||
|
<ol className="list-decimal list-inside space-y-1 text-sm text-zinc-700 dark:text-zinc-200">
|
||||||
|
{questions.map((q, i) => (
|
||||||
|
<li key={i}>{q}</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Rubric */}
|
||||||
|
<div className="rounded bg-zinc-50 dark:bg-zinc-700/80 p-2.5 font-mono text-xs">
|
||||||
|
{rubricLabels.map((r, i) => (
|
||||||
|
<div key={i} className="text-zinc-600 dark:text-zinc-300">
|
||||||
|
<span className="text-cyan-600 dark:text-cyan-500/80">{i + 1}</span> {r}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick score */}
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-xs text-zinc-500 mr-2">Score:</span>
|
||||||
|
{[1, 2, 3, 4, 5].map((n) => (
|
||||||
|
<button
|
||||||
|
key={n}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onScoreChange(dimension.id, { score: n })}
|
||||||
|
className={`w-8 h-8 rounded font-mono text-sm font-medium transition-colors ${
|
||||||
|
currentScore === n
|
||||||
|
? "bg-cyan-500 text-white"
|
||||||
|
: "bg-zinc-200 dark:bg-zinc-600 text-zinc-600 dark:text-zinc-300 hover:bg-zinc-300 dark:hover:bg-zinc-500 hover:text-zinc-800 dark:hover:text-zinc-100"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{n}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{currentScore != null && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onScoreChange(dimension.id, { score: null })}
|
||||||
|
className="ml-1 text-xs text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300"
|
||||||
|
>
|
||||||
|
reset
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes candidat */}
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-zinc-500 mb-1 block">Notes candidat</label>
|
||||||
|
<textarea
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => {
|
||||||
|
setNotes(e.target.value);
|
||||||
|
onScoreChange(dimension.id, { candidateNotes: e.target.value });
|
||||||
|
}}
|
||||||
|
rows={2}
|
||||||
|
className={inputClass}
|
||||||
|
placeholder="Réponses du candidat..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Justification, exemples, confiance */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-zinc-500">Justification</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={score?.justification ?? ""}
|
||||||
|
onChange={(e) => onScoreChange(dimension.id, { justification: e.target.value || null })}
|
||||||
|
className={inputClass}
|
||||||
|
placeholder="Courte..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-zinc-500">Exemples</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={score?.examplesObserved ?? ""}
|
||||||
|
onChange={(e) => onScoreChange(dimension.id, { examplesObserved: e.target.value || null })}
|
||||||
|
className={inputClass}
|
||||||
|
placeholder="Concrets..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-zinc-500">Confiance</label>
|
||||||
|
<select
|
||||||
|
value={score?.confidence ?? ""}
|
||||||
|
onChange={(e) => onScoreChange(dimension.id, { confidence: e.target.value || null })}
|
||||||
|
className={inputClass}
|
||||||
|
>
|
||||||
|
<option value="">—</option>
|
||||||
|
<option value="low">Faible</option>
|
||||||
|
<option value="med">Moyenne</option>
|
||||||
|
<option value="high">Haute</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
51
src/components/ExportModal.tsx
Normal file
51
src/components/ExportModal.tsx
Normal 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
28
src/components/Header.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
76
src/components/RadarChart.tsx
Normal file
76
src/components/RadarChart.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
src/components/ThemeProvider.tsx
Normal file
40
src/components/ThemeProvider.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
18
src/components/ThemeToggle.tsx
Normal file
18
src/components/ThemeToggle.tsx
Normal 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
7
src/lib/db.ts
Normal 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;
|
||||||
91
src/lib/export-utils.test.ts
Normal file
91
src/lib/export-utils.test.ts
Normal 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
81
src/lib/export-utils.ts
Normal 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;
|
||||||
|
}
|
||||||
21
tests/e2e/dashboard.spec.ts
Normal file
21
tests/e2e/dashboard.spec.ts
Normal 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
16
vitest.config.ts
Normal 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"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user