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
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
pnpm install
|
||||
cp .env.example .env
|
||||
pnpm db:generate
|
||||
pnpm db:push
|
||||
pnpm db:seed
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
## Run
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
Open [http://localhost:3000](http://localhost:3000).
|
||||
|
||||
## Learn More
|
||||
## Seed Data
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
- **3 candidates** with sample evaluations (Alice Chen, Bob Martin, Carol White)
|
||||
- **2 templates**: Full 15-dimensions, Short 8-dimensions
|
||||
- **Admin user**: `admin@cars-front.local` (mock auth)
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
## API Routes
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
| Route | Method | Description |
|
||||
|-------|--------|-------------|
|
||||
| `/api/evaluations` | GET, POST | List / create evaluations |
|
||||
| `/api/evaluations/[id]` | GET, PUT | Get / update evaluation |
|
||||
| `/api/templates` | GET | List templates |
|
||||
| `/api/export/csv?id=` | GET | Export evaluation as CSV |
|
||||
| `/api/export/pdf?id=` | GET | Export evaluation as PDF |
|
||||
| `/api/auth` | GET, POST | Mock auth |
|
||||
| `/api/ai/suggest-followups` | POST | AI follow-up suggestions (stub) |
|
||||
|
||||
## Deploy on Vercel
|
||||
## Export cURL Examples
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
```bash
|
||||
# CSV export (replace EVAL_ID with actual evaluation id)
|
||||
curl -o evaluation.csv "http://localhost:3000/api/export/csv?id=EVAL_ID"
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
# PDF export
|
||||
curl -o evaluation.pdf "http://localhost:3000/api/export/pdf?id=EVAL_ID"
|
||||
```
|
||||
|
||||
With auth header (when real auth is added):
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer YOUR_TOKEN" -o evaluation.csv "http://localhost:3000/api/export/csv?id=EVAL_ID"
|
||||
```
|
||||
|
||||
## AI Assistant Stub
|
||||
|
||||
The AI assistant is a **client-side stub** that returns deterministic follow-up suggestions based on:
|
||||
- Dimension name
|
||||
- Candidate answer length
|
||||
- Current score (low scores trigger probing questions)
|
||||
|
||||
**To plug a real LLM:**
|
||||
|
||||
1. Create or update `/api/ai/suggest-followups` to call OpenAI/Anthropic/etc.
|
||||
2. Pass `{ dimensionName, candidateAnswer, currentScore }` in the request body.
|
||||
3. Use a prompt like: *"Given this dimension and candidate answer, suggest 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",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"prisma": {
|
||||
"seed": "tsx prisma/seed.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
"lint": "eslint",
|
||||
"db:generate": "prisma generate",
|
||||
"db:push": "prisma db push",
|
||||
"db:seed": "tsx prisma/seed.ts",
|
||||
"db:studio": "prisma studio",
|
||||
"test": "vitest run",
|
||||
"test:e2e": "playwright test"
|
||||
},
|
||||
"dependencies": {
|
||||
"date-fns": "^4.1.0",
|
||||
"jspdf": "^4.2.0",
|
||||
"jspdf-autotable": "^5.0.7",
|
||||
"next": "16.1.6",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3"
|
||||
"react-dom": "19.2.3",
|
||||
"recharts": "^3.7.0",
|
||||
"@prisma/client": "^5.22.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"jsdom": "^28.1.0",
|
||||
"prisma": "^5.22.0",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^5",
|
||||
"vitest": "^4.0.18"
|
||||
}
|
||||
}
|
||||
}
|
||||
20
playwright.config.ts
Normal file
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";
|
||||
|
||||
@custom-variant dark (&:where(.dark, .dark *));
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
--accent: #0891b2;
|
||||
--accent-hover: #0e7490;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
body {
|
||||
font-family: var(--font-sans), system-ui, sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
/* Tech input styling */
|
||||
input, select, textarea {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
input:focus, select:focus, textarea:focus {
|
||||
outline: none;
|
||||
ring: 2px;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { Header } from "@/components/Header";
|
||||
import { ThemeProvider } from "@/components/ThemeProvider";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
@@ -13,8 +15,8 @@ const geistMono = Geist_Mono({
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "Évaluateur Maturité IA Gen",
|
||||
description: "Équipe Cars Front - Outil d'évaluation de la maturité IA Gen",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -23,11 +25,12 @@ export default function RootLayout({
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
{children}
|
||||
<html lang="fr" suppressHydrationWarning>
|
||||
<body className={`${geistSans.variable} ${geistMono.variable} min-h-screen bg-zinc-100 text-zinc-900 dark:bg-zinc-950 dark:text-zinc-50 antialiased`}>
|
||||
<ThemeProvider>
|
||||
<Header />
|
||||
<main className="mx-auto max-w-5xl px-4 py-6">{children}</main>
|
||||
</ThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
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 (
|
||||
<div className="flex min-h-screen items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<main className="flex min-h-screen w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||
To get started, edit the page.tsx file.
|
||||
</h1>
|
||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
Looking for a starting point or more instructions? Head over to{" "}
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Templates
|
||||
</a>{" "}
|
||||
or the{" "}
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Deploy Now
|
||||
</a>
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<h1 className="font-mono text-lg font-medium text-zinc-800 dark:text-zinc-100">Évaluations</h1>
|
||||
<Link
|
||||
href="/evaluations/new"
|
||||
className="rounded border border-cyan-500/50 bg-cyan-500/10 px-3 py-1.5 font-mono text-xs text-cyan-600 dark:text-cyan-400 hover:bg-cyan-500/20 transition-colors"
|
||||
>
|
||||
+ nouvelle
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-lg border border-zinc-200 dark:border-zinc-600 bg-white dark:bg-zinc-800 shadow-sm dark:shadow-none">
|
||||
<table className="min-w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-200 dark:border-zinc-600 bg-zinc-50 dark:bg-zinc-700/80">
|
||||
<th className="px-4 py-2.5 text-left font-mono text-xs text-zinc-600 dark:text-zinc-400">Candidat</th>
|
||||
<th className="px-4 py-2.5 text-left font-mono text-xs text-zinc-600 dark:text-zinc-400">Rôle</th>
|
||||
<th className="px-4 py-2.5 text-left font-mono text-xs text-zinc-600 dark:text-zinc-400">Évaluateur</th>
|
||||
<th className="px-4 py-2.5 text-left font-mono text-xs text-zinc-600 dark:text-zinc-400">Date</th>
|
||||
<th className="px-4 py-2.5 text-left font-mono text-xs text-zinc-600 dark:text-zinc-400">Modèle</th>
|
||||
<th className="px-4 py-2.5 text-left font-mono text-xs text-zinc-600 dark:text-zinc-400">Statut</th>
|
||||
<th className="px-4 py-2.5 text-right font-mono text-xs text-zinc-600 dark:text-zinc-400">—</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-12 text-center font-mono text-xs text-zinc-600 dark:text-zinc-500">
|
||||
loading...
|
||||
</td>
|
||||
</tr>
|
||||
) : evaluations.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="px-4 py-12 text-center text-zinc-600 dark:text-zinc-500">
|
||||
Aucune évaluation.{" "}
|
||||
<Link href="/evaluations/new" className="text-cyan-600 dark:text-cyan-400 hover:underline">
|
||||
Créer
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
evaluations.map((e) => (
|
||||
<tr key={e.id} className="border-b border-zinc-200 dark:border-zinc-600/50 hover:bg-zinc-50 dark:hover:bg-zinc-700/50 transition-colors">
|
||||
<td className="px-4 py-2.5 text-sm font-medium text-zinc-800 dark:text-zinc-100">{e.candidateName}</td>
|
||||
<td className="px-4 py-2.5 text-sm text-zinc-600 dark:text-zinc-400">{e.candidateRole}</td>
|
||||
<td className="px-4 py-2.5 text-sm text-zinc-600 dark:text-zinc-400">{e.evaluatorName}</td>
|
||||
<td className="px-4 py-2.5 font-mono text-xs text-zinc-600 dark:text-zinc-400">
|
||||
{format(new Date(e.evaluationDate), "yyyy-MM-dd")}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-sm text-zinc-600 dark:text-zinc-400">{e.template?.name ?? ""}</td>
|
||||
<td className="px-4 py-2.5">
|
||||
<span
|
||||
className={`font-mono text-xs px-1.5 py-0.5 rounded ${
|
||||
e.status === "submitted" ? "bg-emerald-500/20 text-emerald-600 dark:text-emerald-400" : "bg-amber-500/20 text-amber-600 dark:text-amber-400"
|
||||
}`}
|
||||
>
|
||||
{e.status === "submitted" ? "ok" : "draft"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-right">
|
||||
<span className="inline-flex items-center gap-3">
|
||||
<Link
|
||||
href={`/evaluations/${e.id}`}
|
||||
className="font-mono text-xs text-cyan-600 dark:text-cyan-400 hover:text-cyan-500 dark:hover:text-cyan-300"
|
||||
>
|
||||
→
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
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