Compare commits

...

37 Commits

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 09:54:31 +01:00
d9073c29bc fix: update middleware matcher to exclude additional image formats
- Enhanced the matcher configuration to exclude SVG, PNG, JPG, JPEG, GIF, WEBP, and ICO file types from middleware processing, improving asset handling.
2026-02-25 09:36:57 +01:00
cfde81b8de feat: auto-save ciblé au blur avec feedback violet sur tous les champs
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 7m6s
- Nouvelle action updateDimensionScore pour sauvegarder un seul champ
  en base sans envoyer tout le formulaire
- DimensionCard : blur sur notes, justification, exemples, confiance
  → upsert ciblé + bordure violette 800ms
- CandidateForm : même pattern sur tous les champs du cartouche
- Bouton save passe aussi en violet (cohérence visuelle)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 08:29:51 +01:00
437b5db1da feat: auto-save notes candidat au blur avec indicateur de modification
Le champ "Notes candidat" passe en bordure ambre tant qu'il y a des
changements non sauvegardés. Au défocus, la sauvegarde se déclenche
automatiquement et la bordure revient à la normale.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 08:17:58 +01:00
c1751a1ab6 feat: animation de confirmation sur le bouton save
Ajoute 3 états visuels au bouton save : repos (gris), saving (spinner),
saved (fond vert + checkmark animé pendant 2 secondes).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-25 08:14:35 +01:00
895df3f7d9 feat: grille dashboard fluide, conteneur élargi et theme toggle plus visible
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 4m0s
- max-w-5xl → max-w-7xl sur le layout global
- grille auto-fill minmax(300px,1fr) pour grands écrans
- ThemeToggle : bordure + fond permanents, emoji text-lg

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 13:55:26 +01:00
92c819d339 feat: afficher badge privé (violet) dans les cards du dashboard
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-23 13:49:03 +01:00
87326b459e Refactor EvaluationEditor component by removing unused Link import. Enhance ExportModal to include Confluence export option and update export-utils with functions for parsing questions and rubrics, and generating Confluence markup for evaluations.
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2026-02-23 13:24:36 +01:00
Julien Froidefond
9ff745489f Enhance database seeding and update seed questions. Add database seeding command to docker-start.sh and introduce new questions related to support and scaling in seed.ts, improving the data initialization process.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m17s
2026-02-20 16:44:35 +01:00
Julien Froidefond
dee59991fc docs: réécriture README complet en français
README entièrement reécrit en français pour les développeurs Peaksys :
architecture server-first, auth bcrypt/rôles, Docker, variables d'env,
pages & fonctionnalités, structure des fichiers à jour.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 16:25:27 +01:00
Julien Froidefond
160e90fbde Add name change functionality to user settings. Update SettingsPasswordForm to handle name updates, including validation and error handling. Fetch current user name for display in settings page.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m44s
2026-02-20 14:47:32 +01:00
Julien Froidefond
8073321b0f Implement evaluation grouping by team and enhance DashboardClient with view mode selection. Add EvalCard component for improved evaluation display, including radar chart visualization and delete functionality.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m29s
2026-02-20 14:11:52 +01:00
Julien Froidefond
aab8a192d4 Refactor evaluation and admin pages to use server actions for data fetching, enhancing performance and simplifying state management. Update README to reflect API route changes and remove deprecated API endpoints for users and evaluations.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m7s
2026-02-20 14:08:18 +01:00
Julien Froidefond
2ef9b4d6f9 Add isPublic field to evaluation API and detail page for public visibility management
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m6s
2026-02-20 13:55:21 +01:00
Julien Froidefond
dc8581f545 Update Dockerfile to add a new startup script and adjust permissions for entrypoint and startup scripts, simplifying the command execution process.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m54s
2026-02-20 13:46:42 +01:00
Julien Froidefond
521975db31 Update Docker Compose configuration to set a default volume path for the database and modify the deployment workflow to conditionally create the database directory based on the provided environment variable.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m4s
2026-02-20 13:42:28 +01:00
Julien Froidefond
04d5a9b9c2 Update Dockerfile to streamline command execution by removing the database seeding step from the CMD instruction, simplifying the deployment process.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m5s
2026-02-20 13:36:41 +01:00
Julien Froidefond
65fee6baf7 Refactor Header component to improve code readability by formatting Link elements and adding a new settings link for user navigation.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m12s
2026-02-20 13:31:23 +01:00
Julien Froidefond
e30cfedea8 Update branding references throughout the application to reflect Peaksys as the developer, including changes to the README, admin user email, candidate team names, and metadata descriptions.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m36s
2026-02-20 13:27:44 +01:00
Julien Froidefond
328200f8b4 Update routing logic to redirect users to the dashboard after login and evaluation actions. Refactor middleware to handle public routes and adjust navigation links across the application for improved user experience.
Some checks failed
Deploy with Docker Compose / deploy (push) Has been cancelled
2026-02-20 13:26:53 +01:00
Julien Froidefond
b1fb6762fe Refactor sign-out functionality in Header component to use async/await for improved navigation handling, ensuring a smoother user experience during logout.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m40s
2026-02-20 13:22:52 +01:00
Julien Froidefond
59f82e4072 Add AUTH_SECRET environment variable to Docker Compose configuration for enhanced security in service authentication.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m21s
2026-02-20 13:18:10 +01:00
Julien Froidefond
9d8d1b257d Refactor Docker Compose configuration to use dynamic volume paths, update deployment workflow to create necessary directories, and enhance Prisma schema with public visibility for evaluations. Improve access control in API routes and adjust evaluation creation logic to include public visibility. Fix minor issues in login and evaluation pages for better user experience.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m17s
2026-02-20 13:13:41 +01:00
Julien Froidefond
f5cbc578b7 Update Dockerfile and package.json to use Prisma migrations, add bcryptjs and next-auth dependencies, and enhance README instructions for database setup. Refactor Prisma schema to include password hashing for users and implement evaluation sharing functionality. Improve admin page with user management features and integrate session handling for authentication. Enhance evaluation detail page with sharing options and update API routes for access control based on user roles.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m4s
2026-02-20 12:58:47 +01:00
Julien Froidefond
9a734dc1ed Refactor seed data to upsert candidates and evaluations, ensuring existing evaluations are updated without clearing previous data. Enhance the evaluation creation process with detailed scoring and justification for improved clarity and relevance.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m19s
2026-02-20 12:33:22 +01:00
Julien Froidefond
34b2a8c5cc Integrate RadarChart component into DashboardPage, enhancing evaluation display with radar data visualization. Update API to include dimensions in template retrieval, and adjust RadarChart for compact mode support.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m7s
2026-02-20 11:59:13 +01:00
Julien Froidefond
edb8125e56 Implement collapse functionality in EvaluationDetailPage and DimensionCard components, allowing users to collapse all dimension cards simultaneously for improved usability and organization. 2026-02-20 11:57:18 +01:00
63 changed files with 3850 additions and 1251 deletions

View File

@@ -16,11 +16,8 @@ jobs:
env:
DOCKER_BUILDKIT: 1
COMPOSE_DOCKER_CLI_BUILD: 1
NEXTAUTH_URL: ${{ vars.NEXTAUTH_URL }}
NEXTAUTH_SECRET: ${{ secrets.NEXTAUTH_SECRET }}
PRISMA_DATA_PATH: ${{ vars.PRISMA_DATA_PATH }}
UPLOADS_PATH: ${{ vars.UPLOADS_PATH }}
POSTGRES_DATA_PATH: ${{ vars.POSTGRES_DATA_PATH }}
POSTGRES_PASSWORD: ${{ secrets.POSTGRES_PASSWORD }}
AUTH_SECRET: ${{ secrets.AUTH_SECRET }}
DB_VOLUME_PATH: ${{ variables.DB_VOLUME_PATH }}
run: |
docker compose up -d --build
if [ -n "${DB_VOLUME_PATH}" ]; then mkdir -p "$DB_VOLUME_PATH"; fi
BUILDKIT_PROGRESS=plain docker compose up -d --build

69
CLAUDE.md Normal file
View File

@@ -0,0 +1,69 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Commands
```bash
pnpm dev # Start dev server
pnpm build # Production build
pnpm lint # ESLint
pnpm typecheck # tsc --noEmit
pnpm test # Vitest unit tests (run once)
pnpm test:e2e # Playwright E2E (requires dev server running)
pnpm db:generate # Regenerate Prisma client after schema changes
pnpm db:push # Sync schema to DB (dev, no migration files)
pnpm db:migrate # Apply migrations (production)
pnpm db:seed # Seed with sample data
pnpm db:studio # Open Prisma Studio
```
Package manager: **pnpm**.
## Coding conventions
### Server-first by default
This codebase maximises server-side rendering and minimises client-side JavaScript:
- **All pages are server components** by default. Never add `"use client"` to a page file.
- **Data fetching always happens server-side** in `src/lib/server-data.ts` — never `fetch()` from the client, never call Prisma from a client component.
- **All mutations use server actions** (`src/actions/`) — never create new API routes for mutations. The only remaining API routes are the export endpoints and auth (see below).
- **`"use client"` is only added** to components that genuinely need browser APIs or React state (forms, interactive widgets). Keep the surface area small.
- **Auth checks happen in every server action** via `const session = await auth()` before touching the DB.
### Data flow pattern
- **Server page** calls `src/lib/server-data.ts` functions (which call `auth()` + Prisma internally)
- Page passes serialized data as props to **client components** in `src/components/`
- Client components call **server actions** (`src/actions/`) for mutations
- Server actions call `revalidatePath()` to trigger cache invalidation
### Auth
`src/auth.ts` — NextAuth v5 with Credentials provider (email + bcrypt password). JWT strategy with `id` and `role` added to the token. Two roles: `evaluator` (default) and `admin`.
`src/middleware.ts` — Protects all routes. Admin routes redirect non-admins. Auth routes redirect logged-in users to `/dashboard`.
### Access control
`src/lib/evaluation-access.ts``canAccessEvaluation()` is the single source of truth. An evaluation is accessible if: user is admin, user is the evaluator, evaluation is shared with the user (`EvaluationShare`), or `isPublic` is true (read-only).
### Database
SQLite in dev (`DATABASE_URL=file:./dev.db`), swap to Postgres for production. Schema lives in `prisma/schema.prisma`. Key relations:
- `Evaluation``Template``TemplateDimension[]`
- `Evaluation``DimensionScore[]` (one per dimension, `@@unique([evaluationId, dimensionId])`)
- `Evaluation``EvaluationShare[]` (many users)
- `Evaluation``AuditLog[]`
`TemplateDimension.suggestedQuestions` is stored as a JSON string (array). `TemplateDimension.rubric` is stored as a `"1:X;2:Y;..."` string. Both are parsed client-side.
### API routes (remaining)
Only exports and auth use API routes:
- `GET /api/export/csv?id=` and `GET /api/export/pdf?id=` — use `src/lib/export-utils.ts`
- `POST /api/ai/suggest-followups`**stub**, returns deterministic suggestions; replace with real LLM call if needed
- `POST /api/auth/signup` — user registration
### Key types
`src/types/next-auth.d.ts` extends `Session.user` with `id` and `role`. Always use `session.user.id` (never `session.user.email`) as the user identifier in server actions.
### JWT staleness
`session.user.name` comes from the JWT token frozen at login time. If a page needs the user's current `name` (or any other mutable profile field), query Prisma directly using `session.user.id` — do not rely on the session object for those values.

View File

@@ -44,8 +44,9 @@ ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
COPY docker-entrypoint.sh /entrypoint.sh
COPY docker-start.sh /start.sh
RUN apt-get update -y && apt-get install -y gosu && rm -rf /var/lib/apt/lists/* \
&& chmod +x /entrypoint.sh
&& chmod +x /entrypoint.sh /start.sh
ENTRYPOINT ["/entrypoint.sh"]
CMD ["sh", "-c", "npx prisma db push && npx prisma db seed && node server.js"]
CMD ["/start.sh"]

267
README.md
View File

@@ -1,142 +1,215 @@
# IA Gen Maturity Evaluator
Production-ready web app for evaluating IA/GenAI maturity of candidates. Built for the Cars Front team.
Application web de maturité IA/GenAI pour évaluer les candidats lors d'entretiens techniques. Développée par Peaksys pour Peaksys.
## Tech Stack
## Stack technique
- **Next.js 16** (App Router), **React 19**, **TypeScript**, **TailwindCSS**
- **Prisma** + **SQLite** (local) — switch to Postgres/Supabase for production
- **Recharts** (radar chart), **jsPDF** (PDF export)
- **Next.js 15** (App Router), **React 19**, **TypeScript**, **TailwindCSS**
- **NextAuth v5** — authentification JWT, bcrypt, rôles `admin` / `evaluator`
- **Prisma** + **SQLite** (dev) — basculer sur Postgres/Supabase en production
- **Recharts** — radar chart des scores
- **jsPDF** — export PDF
- **Vitest** — tests unitaires | **Playwright** — tests E2E
## Setup
## Prérequis
- Node.js ≥ 20
- pnpm (`npm install -g pnpm`)
## Installation & démarrage local
```bash
pnpm install
cp .env.example .env
cp .env.example .env # puis remplir AUTH_SECRET
pnpm db:generate
pnpm db:push
pnpm db:push # ou pnpm db:migrate pour une DB vide
pnpm db:seed
```
## Run
```bash
pnpm dev
```
Open [http://localhost:3000](http://localhost:3000).
Ouvrir [http://localhost:3000](http://localhost:3000).
## Seed Data
- **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)
## API Routes
| 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) |
## Export cURL Examples
**Note :** Si la DB existe déjà (créée avec `db push`), pour basculer sur les migrations :
```bash
# CSV export (replace EVAL_ID with actual evaluation id)
curl -o evaluation.csv "http://localhost:3000/api/export/csv?id=EVAL_ID"
# PDF export
curl -o evaluation.pdf "http://localhost:3000/api/export/pdf?id=EVAL_ID"
pnpm prisma migrate resolve --applied 20250220000000_init
```
With auth header (when real auth is added):
## Variables d'environnement
| Variable | Obligatoire | Description |
|----------|-------------|-------------|
| `DATABASE_URL` | Oui | `file:./dev.db` en local, URL Postgres en prod |
| `AUTH_SECRET` | Oui | Secret JWT NextAuth (générer avec `openssl rand -base64 32`) |
| `NEXTAUTH_URL` | Prod | URL publique de l'app (ex. `https://eval.peaksys.io`) |
## Docker
Trois configurations disponibles :
### Développement (hot-reload)
```bash
curl -H "Authorization: Bearer YOUR_TOKEN" -o evaluation.csv "http://localhost:3000/api/export/csv?id=EVAL_ID"
docker compose -f docker-compose.dev.yml up
```
## AI Assistant Stub
### Production — SQLite (simple, port 3044)
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)
```bash
AUTH_SECRET=$(openssl rand -base64 32) \
DB_VOLUME_PATH=/chemin/vers/data \
docker compose up -d
```
**To plug a real LLM:**
### Production — Postgres
1. Create or update `/api/ai/suggest-followups` to call OpenAI/Anthropic/etc.
2. Pass `{ dimensionName, candidateAnswer, currentScore }` in the request body.
3. Use a prompt like: *"Given this dimension and candidate answer, suggest 23 probing interview questions."*
4. Return `{ suggestions: string[] }`.
```bash
docker compose -f docker-compose.postgres.yml up -d
```
The client already calls this API when the user clicks "Get AI follow-up suggestions" in the dimension card.
> Le `Dockerfile` inclut le client Prisma compilé pour Linux (`debian-openssl-3.0.x`).
> La base est migrée automatiquement au démarrage via `docker-start.sh`.
## Architecture
### Flux de données (server-first)
1. **Server Page**`src/lib/server-data.ts` (appelle `auth()` + Prisma)
2. La page passe les données sérialisées aux **Client Components** (`src/components/`)
3. Les composants clients appellent les **Server Actions** (`src/actions/`) pour les mutations
4. Les server actions appellent `revalidatePath()` pour invalider le cache
### Authentification & rôles
- `src/auth.ts` — NextAuth v5, Credentials provider (email + bcrypt), stratégie JWT
- Deux rôles : `evaluator` (défaut) et `admin`
- Inscription via `/auth/signup` (`POST /api/auth/signup`)
- `src/middleware.ts` — protège toutes les routes ; redirige les non-admins hors des routes admin
### Contrôle d'accès aux évaluations
`src/lib/evaluation-access.ts``canAccessEvaluation()` est la source de vérité unique :
- Utilisateur **admin** → accès total
- Utilisateur **evaluator** propriétaire → accès total
- Évaluation **partagée** (`EvaluationShare`) → accès lecture/écriture
- Évaluation **publique** (`isPublic = true`) → accès lecture seule
### Important : staleness du JWT
`session.user.name` est gelé au moment du login. Pour afficher le nom à jour, interroger Prisma via `session.user.id` — ne pas se fier à la session.
## Pages & fonctionnalités
| Route | Accès | Description |
|-------|-------|-------------|
| `/` | Tous | Redirect vers `/dashboard` |
| `/dashboard` | Auth | Liste des évaluations, groupement par équipe, vue carte ou tableau |
| `/evaluations/new` | Auth | Créer une évaluation (candidat, rôle, équipe, template) |
| `/evaluations/[id]` | Voir accès | Guide d'entretien, scores 15, justifications, radar chart, export |
| `/admin` | Admin | Gestion des templates et des utilisateurs |
| `/settings` | Auth | Modifier son nom et son mot de passe |
| `/auth/login` | Public | Connexion |
| `/auth/signup` | Public | Inscription |
### Fonctionnalités clés
- **Scoring** : note 15, justification, exemples observés, niveau de confiance
- **Questions de sondage** : affichées automatiquement quand score ≤ 2
- **Assistant IA (stub)** : suggestions de questions de relance (`/api/ai/suggest-followups`)
- **Export** : CSV (`/api/export/csv?id=`) et PDF (`/api/export/pdf?id=`)
- **Radar chart** : visualisation des scores par dimension
- **Partage** : partager une évaluation avec d'autres utilisateurs (`EvaluationShare`)
- **Visibilité publique** : `isPublic` rend une évaluation lisible sans authentification
- **Audit log** : toute modification post-soumission est tracée
- **Warning** : alerte si tous les scores = 5 sans justification
## Commandes utiles
```bash
pnpm dev # Serveur de développement
pnpm build # Build production
pnpm lint # ESLint
pnpm typecheck # tsc --noEmit
pnpm db:generate # Régénérer le client Prisma après modif du schéma
pnpm db:push # Synchroniser le schéma (dev, sans fichiers de migration)
pnpm db:migrate # Appliquer les migrations (production)
pnpm db:seed # Injecter les données de seed
pnpm db:studio # Ouvrir Prisma Studio
```
## Tests
```bash
# Unit tests (Vitest)
# Tests unitaires (Vitest)
pnpm test
# E2E tests (Playwright) — requires dev server
# Tests E2E (Playwright) — nécessite le serveur dev lancé
pnpm exec playwright install # une seule fois
pnpm test:e2e
```
Run `pnpm exec playwright install` once to install browsers for E2E.
## Déploiement (production)
## Deploy
1. Configurer `DATABASE_URL` vers Postgres (Supabase, Neon, etc.)
2. Générer `AUTH_SECRET` : `openssl rand -base64 32`
3. Appliquer les migrations : `pnpm db:migrate`
4. Seeder si besoin : `pnpm db:seed`
5. Build : `pnpm build && pnpm start`
6. Ou déployer sur Vercel (variables d'env à configurer, Vercel Postgres ou DB externe)
1. Set `DATABASE_URL` to Postgres (e.g. Supabase, Neon).
2. Run migrations: `pnpm db:push`
3. Seed if needed: `pnpm db:seed`
4. Build: `pnpm build && pnpm start`
5. Or deploy to Vercel (set env, use Vercel Postgres or external DB).
## Acceptance Criteria
- [x] Auth: mock single-admin login
- [x] Dashboard: list evaluations and candidates
- [x] Create/Edit evaluation: candidate, role, date, evaluator, template
- [x] Templates: Full 15-dim, Short 8-dim
- [x] Interview guide: definition, rubric 1→5, signals, questions per dimension
- [x] AI assistant: stub suggests follow-ups
- [x] Scoring: 15, justification, examples, confidence
- [x] Probing questions when score ≤ 2
- [x] Radar chart + findings/recommendations
- [x] Export PDF and CSV
- [x] Admin: view templates
- [x] Warning when all scores = 5 without comments
- [x] Edit after submission (audit log)
- [x] Mobile responsive (Tailwind)
## Manual Test Plan
1. **Dashboard**: Open `/`, verify evaluations table or empty state.
2. **New evaluation**: Click "New Evaluation", fill form, select template, submit.
3. **Interview guide**: On evaluation page, score dimensions, add notes, click "Get AI follow-up suggestions".
4. **Low score**: Set a dimension to 1 or 2, verify probing questions appear.
5. **All 5s**: Set all scores to 5 with no justification, submit — verify warning.
6. **Aggregate**: Click "Auto-generate findings", verify radar chart and text.
7. **Export**: Click Export, download CSV and PDF.
8. **Admin**: Open `/admin`, verify templates listed.
## File Structure
## Structure des fichiers
```
src/
├── app/
│ ├── api/ # API routes
│ ├── evaluations/ # Evaluation pages
│ ├── admin/ # Admin page
── page.tsx # Dashboard
├── components/ # UI components
└── lib/ # Utils, db, ai-stub, export-utils
│ ├── api/ # Routes API (export CSV/PDF, auth, AI stub)
│ ├── auth/ # Pages login / signup
│ ├── dashboard/ # Dashboard principal
── evaluations/ # Pages évaluation (new, [id])
│ ├── admin/ # Administration (templates, utilisateurs)
│ ├── settings/ # Paramètres utilisateur
│ └── page.tsx # Redirect vers /dashboard
├── actions/ # Server Actions (mutations)
│ ├── evaluations.ts
│ ├── share.ts
│ ├── admin.ts
│ └── password.ts
├── components/ # Composants UI (client + server)
└── lib/
├── server-data.ts # Toutes les requêtes DB (server-side)
├── evaluation-access.ts
├── export-utils.ts
└── ai-stub.ts
prisma/
├── schema.prisma
└── seed.ts
tests/e2e/ # Playwright E2E
tests/e2e/ # Tests Playwright
```
## Données de seed
- **Utilisateur admin** : `admin@peaksys.local` / `admin123`
- **3 évaluations** de démonstration (Alice Chen, Bob Martin, Carol White) avec équipes
- **2 templates** : Full 15 dimensions, Short 8 dimensions
## Critères d'acceptation
- [x] Auth bcrypt multi-utilisateurs, rôles admin/evaluator
- [x] Dashboard : liste et cartes d'évaluations, groupement par équipe
- [x] Créer/modifier une évaluation : candidat, rôle, équipe, date, template
- [x] Templates : Full 15 dim, Short 8 dim
- [x] Guide d'entretien : définition, rubrique 1→5, signaux, questions par dimension
- [x] Scoring : note 15, justification, exemples, confiance
- [x] Questions de sondage quand score ≤ 2
- [x] Assistant IA (stub) : suggestions de relance
- [x] Radar chart + findings/recommandations auto-générés
- [x] Export PDF et CSV
- [x] Administration : templates et gestion des utilisateurs
- [x] Partage d'évaluation avec d'autres utilisateurs
- [x] Visibilité publique (`isPublic`)
- [x] Alerte si tous les scores = 5 sans commentaires
- [x] Modification post-soumission avec audit log
- [x] Page paramètres : changer nom et mot de passe
- [x] Responsive mobile (Tailwind)

View File

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

View File

@@ -1,12 +1,13 @@
services:
app:
iag-dev-evaluator:
build: .
ports:
- "3044:3000"
environment:
- DATABASE_URL=file:/data/db/dev.db
- AUTH_SECRET=${AUTH_SECRET}
volumes:
- db-data:/data/db
- ${DB_VOLUME_PATH:-db-data}:/data/db
restart: unless-stopped
volumes:

17
docker-start.sh Normal file
View File

@@ -0,0 +1,17 @@
#!/bin/sh
set -e
# Run migrations; if P3005 (schema not empty, no migration history), baseline then retry
if ! npx prisma migrate deploy 2>/dev/null; then
echo "Migration failed, attempting baseline (P3005 fix)..."
for dir in /app/prisma/migrations/*/; do
[ -d "$dir" ] || continue
name=$(basename "$dir")
npx prisma migrate resolve --applied "$name" 2>/dev/null || true
done
npx prisma migrate deploy
fi
npx prisma db seed
exec node server.js

View File

@@ -16,19 +16,23 @@
"db:seed": "tsx prisma/seed.ts",
"db:studio": "prisma studio",
"test": "vitest run",
"test:e2e": "playwright test"
"test:e2e": "playwright test",
"db:migrate": "prisma migrate deploy"
},
"dependencies": {
"bcryptjs": "^2.4.3",
"date-fns": "^4.1.0",
"jspdf": "^4.2.0",
"jspdf-autotable": "^5.0.7",
"next": "16.1.6",
"next-auth": "^5.0.0-beta.25",
"react": "19.2.3",
"react-dom": "19.2.3",
"recharts": "^3.7.0",
"@prisma/client": "^5.22.0"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@playwright/test": "^1.58.2",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",

92
pnpm-lock.yaml generated
View File

@@ -11,6 +11,9 @@ importers:
'@prisma/client':
specifier: ^5.22.0
version: 5.22.0(prisma@5.22.0)
bcryptjs:
specifier: ^2.4.3
version: 2.4.3
date-fns:
specifier: ^4.1.0
version: 4.1.0
@@ -23,6 +26,9 @@ importers:
next:
specifier: 16.1.6
version: 16.1.6(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
next-auth:
specifier: ^5.0.0-beta.25
version: 5.0.0-beta.30(next@16.1.6(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3)
react:
specifier: 19.2.3
version: 19.2.3
@@ -39,6 +45,9 @@ importers:
'@tailwindcss/postcss':
specifier: ^4
version: 4.2.0
'@types/bcryptjs':
specifier: ^2.4.6
version: 2.4.6
'@types/node':
specifier: ^20
version: 20.19.33
@@ -94,6 +103,20 @@ packages:
'@asamuzakjp/nwsapi@2.3.9':
resolution: {integrity: sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==}
'@auth/core@0.41.0':
resolution: {integrity: sha512-Wd7mHPQ/8zy6Qj7f4T46vg3aoor8fskJm6g2Zyj064oQ3+p0xNZXAV60ww0hY+MbTesfu29kK14Zk5d5JTazXQ==}
peerDependencies:
'@simplewebauthn/browser': ^9.0.1
'@simplewebauthn/server': ^9.0.2
nodemailer: ^6.8.0
peerDependenciesMeta:
'@simplewebauthn/browser':
optional: true
'@simplewebauthn/server':
optional: true
nodemailer:
optional: true
'@babel/code-frame@7.29.0':
resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==}
engines: {node: '>=6.9.0'}
@@ -670,6 +693,9 @@ packages:
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
engines: {node: '>=12.4.0'}
'@panva/hkdf@1.2.1':
resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==}
'@playwright/test@1.58.2':
resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==}
engines: {node: '>=18'}
@@ -953,6 +979,9 @@ packages:
'@types/babel__traverse@7.28.0':
resolution: {integrity: sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==}
'@types/bcryptjs@2.4.6':
resolution: {integrity: sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==}
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
@@ -1302,6 +1331,9 @@ packages:
engines: {node: '>=6.0.0'}
hasBin: true
bcryptjs@2.4.3:
resolution: {integrity: sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==}
bidi-js@1.0.3:
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
@@ -2028,6 +2060,9 @@ packages:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true
jose@6.1.3:
resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==}
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -2225,6 +2260,22 @@ packages:
natural-compare@1.4.0:
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
next-auth@5.0.0-beta.30:
resolution: {integrity: sha512-+c51gquM3F6nMVmoAusRJ7RIoY0K4Ts9HCCwyy/BRoe4mp3msZpOzYMyb5LAYc1wSo74PMQkGDcaghIO7W6Xjg==}
peerDependencies:
'@simplewebauthn/browser': ^9.0.1
'@simplewebauthn/server': ^9.0.2
next: ^14.0.0-0 || ^15.0.0 || ^16.0.0
nodemailer: ^7.0.7
react: ^18.2.0 || ^19.0.0
peerDependenciesMeta:
'@simplewebauthn/browser':
optional: true
'@simplewebauthn/server':
optional: true
nodemailer:
optional: true
next@16.1.6:
resolution: {integrity: sha512-hkyRkcu5x/41KoqnROkfTm2pZVbKxvbZRuNvKXLRXxs3VfyO0WhY50TQS40EuKO9SW3rBj/sF3WbVwDACeMZyw==}
engines: {node: '>=20.9.0'}
@@ -2253,6 +2304,9 @@ packages:
node-releases@2.0.27:
resolution: {integrity: sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==}
oauth4webapi@3.8.5:
resolution: {integrity: sha512-A8jmyUckVhRJj5lspguklcl90Ydqk61H3dcU0oLhH3Yv13KpAliKTt5hknpGGPZSSfOwGyraNEFmofDYH+1kSg==}
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
@@ -2364,6 +2418,14 @@ packages:
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
engines: {node: ^10 || ^12 || >=14}
preact-render-to-string@6.5.11:
resolution: {integrity: sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==}
peerDependencies:
preact: '>=10'
preact@10.24.3:
resolution: {integrity: sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==}
prelude-ls@1.2.1:
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
engines: {node: '>= 0.8.0'}
@@ -2929,6 +2991,14 @@ snapshots:
'@asamuzakjp/nwsapi@2.3.9': {}
'@auth/core@0.41.0':
dependencies:
'@panva/hkdf': 1.2.1
jose: 6.1.3
oauth4webapi: 3.8.5
preact: 10.24.3
preact-render-to-string: 6.5.11(preact@10.24.3)
'@babel/code-frame@7.29.0':
dependencies:
'@babel/helper-validator-identifier': 7.28.5
@@ -3389,6 +3459,8 @@ snapshots:
'@nolyfill/is-core-module@1.0.39': {}
'@panva/hkdf@1.2.1': {}
'@playwright/test@1.58.2':
dependencies:
playwright: 1.58.2
@@ -3612,6 +3684,8 @@ snapshots:
dependencies:
'@babel/types': 7.29.0
'@types/bcryptjs@2.4.6': {}
'@types/chai@5.2.3':
dependencies:
'@types/deep-eql': 4.0.2
@@ -3983,6 +4057,8 @@ snapshots:
baseline-browser-mapping@2.10.0: {}
bcryptjs@2.4.3: {}
bidi-js@1.0.3:
dependencies:
require-from-string: 2.0.2
@@ -4896,6 +4972,8 @@ snapshots:
jiti@2.6.1: {}
jose@6.1.3: {}
js-tokens@4.0.0: {}
js-yaml@4.1.1:
@@ -5078,6 +5156,12 @@ snapshots:
natural-compare@1.4.0: {}
next-auth@5.0.0-beta.30(next@16.1.6(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(react@19.2.3):
dependencies:
'@auth/core': 0.41.0
next: 16.1.6(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
react: 19.2.3
next@16.1.6(@babel/core@7.29.0)(@playwright/test@1.58.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
dependencies:
'@next/env': 16.1.6
@@ -5112,6 +5196,8 @@ snapshots:
node-releases@2.0.27: {}
oauth4webapi@3.8.5: {}
object-assign@4.1.1: {}
object-inspect@1.13.4: {}
@@ -5228,6 +5314,12 @@ snapshots:
picocolors: 1.1.1
source-map-js: 1.2.1
preact-render-to-string@6.5.11(preact@10.24.3):
dependencies:
preact: 10.24.3
preact@10.24.3: {}
prelude-ls@1.2.1: {}
prisma@5.22.0:

View File

@@ -0,0 +1,100 @@
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL PRIMARY KEY,
"email" TEXT NOT NULL,
"name" TEXT,
"passwordHash" TEXT,
"role" TEXT NOT NULL DEFAULT 'evaluator',
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "Template" (
"id" TEXT NOT NULL PRIMARY KEY,
"name" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL
);
-- CreateTable
CREATE TABLE "TemplateDimension" (
"id" TEXT NOT NULL PRIMARY KEY,
"templateId" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"orderIndex" INTEGER NOT NULL,
"title" TEXT NOT NULL,
"rubric" TEXT NOT NULL,
"suggestedQuestions" TEXT,
CONSTRAINT "TemplateDimension_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "Template" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "Evaluation" (
"id" TEXT NOT NULL PRIMARY KEY,
"candidateName" TEXT NOT NULL,
"candidateRole" TEXT NOT NULL,
"candidateTeam" TEXT,
"evaluatorName" TEXT NOT NULL,
"evaluatorId" TEXT,
"evaluationDate" DATETIME NOT NULL,
"templateId" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'draft',
"findings" TEXT,
"recommendations" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Evaluation_evaluatorId_fkey" FOREIGN KEY ("evaluatorId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "Evaluation_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "Template" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "EvaluationShare" (
"id" TEXT NOT NULL PRIMARY KEY,
"evaluationId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "EvaluationShare_evaluationId_fkey" FOREIGN KEY ("evaluationId") REFERENCES "Evaluation" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "EvaluationShare_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "DimensionScore" (
"id" TEXT NOT NULL PRIMARY KEY,
"evaluationId" TEXT NOT NULL,
"dimensionId" TEXT NOT NULL,
"score" INTEGER,
"justification" TEXT,
"examplesObserved" TEXT,
"confidence" TEXT,
"candidateNotes" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "DimensionScore_evaluationId_fkey" FOREIGN KEY ("evaluationId") REFERENCES "Evaluation" ("id") ON DELETE CASCADE ON UPDATE CASCADE,
CONSTRAINT "DimensionScore_dimensionId_fkey" FOREIGN KEY ("dimensionId") REFERENCES "TemplateDimension" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateTable
CREATE TABLE "AuditLog" (
"id" TEXT NOT NULL PRIMARY KEY,
"evaluationId" TEXT NOT NULL,
"action" TEXT NOT NULL,
"field" TEXT,
"oldValue" TEXT,
"newValue" TEXT,
"userId" TEXT,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "AuditLog_evaluationId_fkey" FOREIGN KEY ("evaluationId") REFERENCES "Evaluation" ("id") ON DELETE CASCADE ON UPDATE CASCADE
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "TemplateDimension_templateId_slug_key" ON "TemplateDimension"("templateId", "slug");
-- CreateIndex
CREATE UNIQUE INDEX "EvaluationShare_evaluationId_userId_key" ON "EvaluationShare"("evaluationId", "userId");
-- CreateIndex
CREATE UNIQUE INDEX "DimensionScore_evaluationId_dimensionId_key" ON "DimensionScore"("evaluationId", "dimensionId");

View File

@@ -0,0 +1,26 @@
-- RedefineTables
PRAGMA defer_foreign_keys=ON;
PRAGMA foreign_keys=OFF;
CREATE TABLE "new_Evaluation" (
"id" TEXT NOT NULL PRIMARY KEY,
"candidateName" TEXT NOT NULL,
"candidateRole" TEXT NOT NULL,
"candidateTeam" TEXT,
"evaluatorName" TEXT NOT NULL,
"evaluatorId" TEXT,
"evaluationDate" DATETIME NOT NULL,
"templateId" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'draft',
"findings" TEXT,
"recommendations" TEXT,
"isPublic" BOOLEAN NOT NULL DEFAULT false,
"createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" DATETIME NOT NULL,
CONSTRAINT "Evaluation_evaluatorId_fkey" FOREIGN KEY ("evaluatorId") REFERENCES "User" ("id") ON DELETE SET NULL ON UPDATE CASCADE,
CONSTRAINT "Evaluation_templateId_fkey" FOREIGN KEY ("templateId") REFERENCES "Template" ("id") ON DELETE RESTRICT ON UPDATE CASCADE
);
INSERT INTO "new_Evaluation" ("candidateName", "candidateRole", "candidateTeam", "createdAt", "evaluationDate", "evaluatorId", "evaluatorName", "findings", "id", "recommendations", "status", "templateId", "updatedAt") SELECT "candidateName", "candidateRole", "candidateTeam", "createdAt", "evaluationDate", "evaluatorId", "evaluatorName", "findings", "id", "recommendations", "status", "templateId", "updatedAt" FROM "Evaluation";
DROP TABLE "Evaluation";
ALTER TABLE "new_Evaluation" RENAME TO "Evaluation";
PRAGMA foreign_keys=ON;
PRAGMA defer_foreign_keys=OFF;

View File

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

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (i.e. Git)
provider = "sqlite"

View File

@@ -15,9 +15,12 @@ model User {
id String @id @default(cuid())
email String @unique
name String?
passwordHash String?
role String @default("evaluator") // evaluator | admin
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
evaluations Evaluation[] @relation("Evaluator")
sharedEvaluations EvaluationShare[]
}
model Template {
@@ -49,6 +52,8 @@ model Evaluation {
candidateRole String
candidateTeam String? // équipe du candidat
evaluatorName String
evaluatorId String?
evaluator User? @relation("Evaluator", fields: [evaluatorId], references: [id], onDelete: SetNull)
evaluationDate DateTime
templateId String
template Template @relation(fields: [templateId], references: [id])
@@ -57,8 +62,25 @@ model Evaluation {
recommendations String?
dimensionScores DimensionScore[]
auditLogs AuditLog[]
sharedWith EvaluationShare[]
isPublic Boolean @default(false) // visible par tous (ex. démo)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([evaluatorId])
@@index([templateId])
}
model EvaluationShare {
id String @id @default(cuid())
evaluationId String
evaluation Evaluation @relation(fields: [evaluationId], references: [id], onDelete: Cascade)
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@unique([evaluationId, userId])
@@index([userId])
}
model DimensionScore {
@@ -88,4 +110,6 @@ model AuditLog {
newValue String?
userId String?
createdAt DateTime @default(now())
@@index([evaluationId])
}

View File

@@ -1,4 +1,5 @@
import { PrismaClient } from "@prisma/client";
import bcrypt from "bcryptjs";
const prisma = new PrismaClient();
@@ -77,6 +78,13 @@ const SUGGESTED_QUESTIONS: Record<string, string[]> = {
"Comment optimisez-vous (choix de modèles, taille du contexte, batch) ?",
"Comment arbitrez-vous coût vs qualité dans vos usages ?",
],
accompagnement: [
"Y a-t-il quelque chose sur lequel vous souhaitez être aidé ou accompagné aujourd'hui ?",
"La Flash Team peut vous aider sur des sujets comme le prompt engineering, la gestion du contexte, ou la mise en place de workflows agentiques — est-ce que l'un de ces axes vous parle ?",
],
scaling: [
"Selon toi, comment pourrais-tu contribuer à mettre à l'échelle tes compétences IA et les outils que tu produis au sein de l'équipe ?",
],
};
const RUBRICS: Record<string, string> = {
@@ -106,6 +114,26 @@ const RUBRICS: Record<string, string> = {
"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",
cost_control:
"1:Inconscient — pas de visibilité sur les coûts, usage sans limite;2:Aware — conscience des coûts, pas de suivi ni de budget;3:Suivi basique — métriques de consommation (tokens, API), pas d'alertes;4:Piloté — budgets par équipe/projet, alertes, arbitrage modèles/qualité;5:Optimisé — optimisation continue (contexte, batch, modèles), ROI coût documenté",
accompagnement:
"1:Aucun besoin exprimé — pas de demande formulée;2:Besoins vagues — envie d'aide sans direction précise;3:Besoins identifiés — sujets d'accompagnement clairs;4:Besoins priorisés — axes de progression définis;5:Plan d'action — besoins concrets et pistes identifiées, prêt à s'engager",
scaling:
"1:Pas de réflexion — aucune idée de comment contribuer au partage;2:Passif — ouvert à partager si sollicité;3:Contributeur ponctuel — partage ses pratiques de temps en temps;4:Multiplicateur — anime des retours d'expérience, documente ses outils;5:Levier d'équipe — impulse une dynamique de diffusion, produit des ressources réutilisables",
};
const RUBRICS_V2: Record<string, string> = {
...RUBRICS,
prompts:
"1:Vague — instructions floues ou incomplètes, l'IA doit deviner l'intention, résultats aléatoires;2:Clair — instructions compréhensibles avec une intention explicite, adapte le niveau de détail à la tâche;3:Précis — donne du contexte utile, précise les contraintes et le résultat attendu, ajuste selon les réponses;4:Méthodique — sait trouver et réutiliser des prompts efficaces, adapte sa formulation selon l'outil et la tâche;5:Maîtrise — spécification \"verrouillée\" : périmètre + définitions + hypothèses + priorités en cas de conflit + critères de sortie/acceptation, minimise l'interprétation et la variabilité des réponses",
conception:
"1:Code direct — pas de phase conception, passage direct au code;2:Conception informelle — réflexion mentale ou notes rapides, pas de formalisation;3:Conception assistée — IA pour esquisser des designs, SDD ou schémas;4:Mode plan structuré — IA utilisée pour explorer options, challenger, documenter les décisions;5:Conception maîtrisée — boucle conception-validation itérative, alternatives comparées et trade-offs explicites avant de coder",
iteration:
"1:One-shot — une seule tentative, pas de retry si le résultat est insuffisant;2:Quelques itérations — 2-3 essais manuels, reformulation si la première réponse échoue;3:Itératif — retry systématique avec reformulation ciblée, sait identifier ce qui ne va pas pour corriger le tir;4:Planifié — découpage en étapes avant de commencer, chaque étape traitée et validée avant la suivante;5:IA sparring partner — dialogue continu avec l'IA pour explorer, affiner, challenger les réponses",
evaluation:
"1:Acceptation — acceptation des sorties sans vérification significative;2:Relecture superficielle — lecture rapide, pas de critères explicites;3:Vérif fonctionnelle — tests manuels ou automatisés, vérification du comportement;4:Regard archi — évaluation de la maintenabilité, cohérence avec les patterns existants;5:Vigilance avancée — détection active des hallucinations et erreurs subtiles, vérification croisée avec d'autres sources, checklist personnelle de contrôle",
alignment:
"1:Hors standards — code généré souvent non conforme, rework systématique;2:Rework fréquent — modifications régulières nécessaires pour aligner le code aux standards;3:Globalement aligné — code généralement conforme, ajustements mineurs, NFR basiques (logs, erreurs) pris en compte;4:Proactif — rules ou instructions dédiées pour respecter standards, archi et NFR (perf, sécurité, observabilité);5:Intégré — NFR systématiquement couverts, garde-fous automatisés (rules, linters, templates), peu ou pas de rework",
cost_control:
"1:Inconscient — pas de visibilité sur les coûts, usage sans limite;2:Aware — conscience des coûts, consulte sa consommation de temps en temps;3:Attentif — choisit le modèle selon la tâche (léger pour le simple, puissant pour le complexe), limite le contexte inutile;4:Économe — optimise activement ses usages (taille du contexte, regroupement de requêtes, évite les générations inutiles);5:Exemplaire — pratiques de sobriété maîtrisées, sait arbitrer coût vs qualité, partage ses astuces d'optimisation",
};
// Réponses réalistes par dimension et score (justification + exemples observés)
@@ -320,7 +348,7 @@ function getDemoResponse(
const TEMPLATES_DATA = [
{
id: "full-15",
name: "Full - 13 dimensions",
name: "Full - 15 dimensions",
dimensions: [
{
id: "tools",
@@ -383,6 +411,93 @@ const TEMPLATES_DATA = [
title: "[Optionnel] Impact sur la delivery",
rubric: RUBRICS.impact,
},
{
id: "accompagnement",
title: "[Optionnel] Accompagnement & besoins",
rubric: RUBRICS.accompagnement,
},
{
id: "scaling",
title: "[Optionnel] Mise à l'échelle des compétences & outils",
rubric: RUBRICS.scaling,
},
],
},
{
id: "full-15-v2",
name: "Full - 15 dimensions (V2)",
dimensions: [
{
id: "tools",
title: "Maîtrise individuelle de l'outillage",
rubric: RUBRICS_V2.tools,
},
{ id: "prompts", title: "Clarté des prompts", rubric: RUBRICS_V2.prompts },
{
id: "conception",
title: "Conception & mode plan (SDD, design)",
rubric: RUBRICS_V2.conception,
},
{
id: "context",
title: "Gestion du contexte",
rubric: RUBRICS_V2.context,
},
{
id: "iteration",
title: "Capacité d'itération",
rubric: RUBRICS_V2.iteration,
},
{
id: "evaluation",
title: "Évaluation critique",
rubric: RUBRICS_V2.evaluation,
},
{
id: "exploration",
title: "Exploration & veille (workflows, astuces, pertinence)",
rubric: RUBRICS_V2.exploration,
},
{
id: "alignment",
title: "Alignement archi & standards",
rubric: RUBRICS_V2.alignment,
},
{
id: "quality_usage",
title: "Usage pour la qualité (tests, review)",
rubric: RUBRICS_V2.quality_usage,
},
{
id: "learning",
title: "Montée en compétence via IA",
rubric: RUBRICS_V2.learning,
},
{
id: "cost_control",
title: "Maîtrise des coûts",
rubric: RUBRICS_V2.cost_control,
},
{
id: "integration",
title: "[Optionnel] Intégration dans les pratiques d'équipe",
rubric: RUBRICS_V2.integration,
},
{
id: "impact",
title: "[Optionnel] Impact sur la delivery",
rubric: RUBRICS_V2.impact,
},
{
id: "accompagnement",
title: "[Optionnel] Accompagnement & besoins",
rubric: RUBRICS_V2.accompagnement,
},
{
id: "scaling",
title: "[Optionnel] Mise à l'échelle des compétences & outils",
rubric: RUBRICS_V2.scaling,
},
],
},
];
@@ -428,22 +543,22 @@ async function main() {
});
}
// Bootstrap demo data uniquement si la DB est vide
const evalCount = await prisma.evaluation.count();
if (evalCount === 0) {
// Upsert répondants (candidates) par nom : create si absent, update si existant. Ne vide pas les évaluations.
const template = await prisma.template.findUnique({
where: { id: "full-15" },
where: { id: "full-15-v2" },
});
if (!template) throw new Error("Template not found");
await prisma.user.upsert({
where: { email: "admin@cars-front.local" },
const adminHash = bcrypt.hashSync("admin123", 10);
const admin = await prisma.user.upsert({
where: { email: "admin@peaksys.local" },
create: {
email: "admin@cars-front.local",
email: "admin@peaksys.local",
name: "Admin User",
passwordHash: adminHash,
role: "admin",
},
update: {},
update: { passwordHash: adminHash },
});
const dims = await prisma.templateDimension.findMany({
@@ -451,38 +566,47 @@ async function main() {
orderBy: { orderIndex: "asc" },
});
const candidates = [
const repondants = [
{
name: "Alice Chen",
role: "Senior ML Engineer",
team: "Cars Front",
team: "Peaksys",
evaluator: "Jean Dupont",
},
{
name: "Bob Martin",
role: "Data Scientist",
team: "Cars Front",
team: "Peaksys",
evaluator: "Marie Curie",
},
{
name: "Carol White",
role: "AI Product Manager",
team: "Cars Data",
team: "Data",
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,
candidateTeam: c.team,
evaluatorName: c.evaluator,
for (let i = 0; i < repondants.length; i++) {
const r = repondants[i];
const existing = await prisma.evaluation.findFirst({
where: {
candidateName: r.name,
evaluatorName: r.evaluator,
},
orderBy: { evaluationDate: "desc" },
});
const evalData = {
candidateName: r.name,
candidateRole: r.role,
candidateTeam: r.team,
evaluatorName: r.evaluator,
evaluatorId: admin.id,
evaluationDate: new Date(2025, 1, 15 + i),
templateId: template.id,
status: i === 0 ? "submitted" : "draft",
isPublic: true, // démo visible par tous
findings:
i === 0
? "Bonne maîtrise des outils et des prompts. Conception et exploration à renforcer. Alignement NFR correct."
@@ -491,8 +615,21 @@ async function main() {
i === 0
? "Encourager le mode plan avant implémentation. Veille sur les workflows IA."
: null,
},
};
let evaluation;
if (existing) {
evaluation = await prisma.evaluation.update({
where: { id: existing.id },
data: evalData,
});
await prisma.dimensionScore.deleteMany({ where: { evaluationId: existing.id } });
} else {
evaluation = await prisma.evaluation.create({
data: evalData,
});
}
for (const d of dims) {
const score = 2 + Math.floor(Math.random() * 3);
const { justification, examplesObserved } = getDemoResponse(d.slug, score);
@@ -508,10 +645,14 @@ async function main() {
});
}
}
console.log("Seed complete: templates synced, demo evaluations created");
} else {
console.log("Seed complete: templates synced, evaluations preserved");
}
// Rattacher les évaluations orphelines (sans evaluatorId) à l'admin
await prisma.evaluation.updateMany({
where: { evaluatorId: null },
data: { evaluatorId: admin.id },
});
console.log("Seed complete: templates synced, répondants upserted (évaluations non vidées)");
}
main()

28
public/icon.svg Normal file
View File

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

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
public/iconfull.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

17
public/manifest.json Normal file
View File

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

42
src/actions/admin.ts Normal file
View File

@@ -0,0 +1,42 @@
"use server";
import { prisma } from "@/lib/db";
import { requireAuth, type ActionResult } from "@/lib/action-helpers";
import { revalidatePath } from "next/cache";
export async function setUserRole(userId: string, role: "admin" | "evaluator"): Promise<ActionResult> {
const session = await requireAuth();
if (!session || session.user.role !== "admin") return { success: false, error: "Accès refusé" };
if (!role || !["admin", "evaluator"].includes(role)) {
return { success: false, error: "Rôle invalide (admin | evaluator)" };
}
try {
await prisma.user.update({ where: { id: userId }, data: { role } });
revalidatePath("/admin");
return { success: true };
} catch (e) {
console.error(e);
return { success: false, error: "Erreur" };
}
}
export async function deleteUser(userId: string): Promise<ActionResult> {
const session = await requireAuth();
if (!session || session.user.role !== "admin") return { success: false, error: "Accès refusé" };
if (userId === session.user.id) {
return { success: false, error: "Impossible de supprimer votre propre compte" };
}
try {
await prisma.user.delete({ where: { id: userId } });
revalidatePath("/admin");
return { success: true };
} catch (e) {
console.error(e);
return { success: false, error: "Erreur" };
}
}

216
src/actions/evaluations.ts Normal file
View File

@@ -0,0 +1,216 @@
"use server";
import { prisma } from "@/lib/db";
import { getEvaluation } from "@/lib/server-data";
import { requireAuth, requireEvaluationAccess, type ActionResult } from "@/lib/action-helpers";
import { revalidatePath } from "next/cache";
export async function fetchEvaluation(id: string): Promise<ActionResult<Awaited<ReturnType<typeof getEvaluation>>>> {
const session = await requireAuth();
if (!session) return { success: false, error: "Non authentifié" };
const evaluation = await getEvaluation(id);
if (!evaluation) return { success: false, error: "Évaluation introuvable" };
return { success: true, data: evaluation };
}
export async function deleteEvaluation(id: string): Promise<ActionResult> {
const session = await requireAuth();
if (!session) return { success: false, error: "Non authentifié" };
const hasAccess = await requireEvaluationAccess(id, session.user.id, session.user.role === "admin");
if (!hasAccess) return { success: false, error: "Accès refusé" };
try {
await prisma.evaluation.delete({ where: { id } });
revalidatePath("/dashboard");
return { success: true };
} catch (e) {
console.error(e);
return { success: false, error: "Erreur lors de la suppression" };
}
}
export async function createEvaluation(data: {
candidateName: string;
candidateRole: string;
candidateTeam?: string;
evaluationDate: string;
templateId: string;
}): Promise<ActionResult<{ id: string }>> {
const session = await requireAuth();
if (!session) return { success: false, error: "Non authentifié" };
const { candidateName, candidateRole, candidateTeam, evaluationDate, templateId } = data;
if (!candidateName || !candidateRole || !evaluationDate || !templateId) {
return { success: false, error: "Champs requis manquants" };
}
try {
const evaluatorName = session.user.name || session.user.email || "Évaluateur";
const template = await prisma.template.findUnique({
where: { id: templateId },
include: { dimensions: { orderBy: { orderIndex: "asc" } } },
});
if (!template) return { success: false, error: "Template introuvable" };
const evaluation = await prisma.evaluation.create({
data: {
candidateName,
candidateRole,
candidateTeam: candidateTeam || null,
evaluatorName,
evaluatorId: session.user.id,
evaluationDate: new Date(evaluationDate),
templateId,
status: "draft",
},
});
await prisma.dimensionScore.createMany({
data: template.dimensions.map((dim) => ({
evaluationId: evaluation.id,
dimensionId: dim.id,
})),
});
revalidatePath("/dashboard");
return { success: true, data: { id: evaluation.id } };
} catch (e) {
console.error(e);
return { success: false, error: "Erreur lors de la création" };
}
}
export interface UpdateEvaluationInput {
candidateName?: string;
candidateRole?: string;
candidateTeam?: string | null;
evaluatorName?: string;
evaluationDate?: string;
status?: string;
findings?: string | null;
recommendations?: string | null;
isPublic?: boolean;
dimensionScores?: {
dimensionId: string;
evaluationId: string;
score: number | null;
justification?: string | null;
examplesObserved?: string | null;
confidence?: string | null;
candidateNotes?: string | null;
}[];
}
export async function updateDimensionScore(
evaluationId: string,
dimensionId: string,
data: { score?: number | null; justification?: string | null; examplesObserved?: string | null; confidence?: string | null; candidateNotes?: string | null }
): Promise<ActionResult> {
const session = await requireAuth();
if (!session) return { success: false, error: "Non authentifié" };
const hasAccess = await requireEvaluationAccess(evaluationId, session.user.id, session.user.role === "admin");
if (!hasAccess) return { success: false, error: "Accès refusé" };
try {
await prisma.dimensionScore.upsert({
where: { evaluationId_dimensionId: { evaluationId, dimensionId } },
update: data,
create: { evaluationId, dimensionId, ...data },
});
revalidatePath(`/evaluations/${evaluationId}`);
return { success: true };
} catch (e) {
return { success: false, error: e instanceof Error ? e.message : "Erreur" };
}
}
export async function updateEvaluation(id: string, data: UpdateEvaluationInput): Promise<ActionResult> {
const session = await requireAuth();
if (!session) return { success: false, error: "Non authentifié" };
const hasAccess = await requireEvaluationAccess(id, session.user.id, session.user.role === "admin");
if (!hasAccess) return { success: false, error: "Accès refusé" };
const existing = await prisma.evaluation.findUnique({ where: { id } });
if (!existing) return { success: false, error: "Évaluation introuvable" };
try {
const {
candidateName,
candidateRole,
candidateTeam,
evaluatorName,
evaluationDate,
status,
findings,
recommendations,
isPublic,
dimensionScores,
} = data;
const updateData: Record<string, unknown> = {};
if (candidateName != null) updateData.candidateName = candidateName;
if (candidateRole != null) updateData.candidateRole = candidateRole;
if (candidateTeam !== undefined) updateData.candidateTeam = candidateTeam;
if (evaluatorName != null) updateData.evaluatorName = evaluatorName;
if (evaluationDate != null) {
const d = new Date(evaluationDate);
if (!isNaN(d.getTime())) updateData.evaluationDate = d;
}
if (status != null) updateData.status = status;
if (findings != null) updateData.findings = findings;
if (recommendations != null) updateData.recommendations = recommendations;
if (typeof isPublic === "boolean") updateData.isPublic = isPublic;
if (Object.keys(updateData).length > 0) {
await prisma.auditLog.create({
data: { evaluationId: id, action: "updated", newValue: JSON.stringify(updateData) },
});
await prisma.evaluation.update({ where: { id }, data: updateData as Record<string, unknown> });
}
if (dimensionScores && Array.isArray(dimensionScores)) {
const validScores = dimensionScores.filter((ds) => ds.dimensionId);
if (validScores.length > 0) {
await prisma.$transaction(
validScores.map((ds) =>
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,
},
})
)
);
}
}
revalidatePath(`/evaluations/${id}`);
revalidatePath("/dashboard");
return { success: true };
} catch (e) {
console.error(e);
return { success: false, error: e instanceof Error ? e.message : "Erreur lors de la sauvegarde" };
}
}

65
src/actions/password.ts Normal file
View File

@@ -0,0 +1,65 @@
"use server";
import bcrypt from "bcryptjs";
import { auth } from "@/auth";
import { prisma } from "@/lib/db";
export type ActionResult = { success: true } | { success: false; error: string };
export async function changeName(newName: string): Promise<ActionResult> {
const session = await auth();
if (!session?.user?.id) return { success: false, error: "Non authentifié" };
const trimmed = newName.trim();
if (!trimmed) return { success: false, error: "Le nom ne peut pas être vide" };
if (trimmed.length > 64) return { success: false, error: "Le nom est trop long (64 caractères max)" };
try {
await prisma.user.update({
where: { id: session.user.id },
data: { name: trimmed },
});
return { success: true };
} catch (e) {
console.error("Name change error:", e);
return { success: false, error: "Erreur lors du changement de nom" };
}
}
export async function changePassword(
currentPassword: string,
newPassword: string
): Promise<ActionResult> {
const session = await auth();
if (!session?.user?.id) return { success: false, error: "Non authentifié" };
if (!currentPassword || !newPassword) {
return { success: false, error: "Mot de passe actuel et nouveau requis" };
}
if (newPassword.length < 8) {
return { success: false, error: "Le nouveau mot de passe doit faire au moins 8 caractères" };
}
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { passwordHash: true },
});
if (!user?.passwordHash) {
return { success: false, error: "Compte sans mot de passe (connexion SSO)" };
}
const ok = await bcrypt.compare(String(currentPassword), user.passwordHash);
if (!ok) return { success: false, error: "Mot de passe actuel incorrect" };
try {
const passwordHash = await bcrypt.hash(String(newPassword), 10);
await prisma.user.update({
where: { id: session.user.id },
data: { passwordHash },
});
return { success: true };
} catch (e) {
console.error("Password change error:", e);
return { success: false, error: "Erreur lors du changement de mot de passe" };
}
}

56
src/actions/share.ts Normal file
View File

@@ -0,0 +1,56 @@
"use server";
import { prisma } from "@/lib/db";
import { requireAuth, requireEvaluationAccess, type ActionResult } from "@/lib/action-helpers";
import { revalidatePath } from "next/cache";
export async function addShare(evaluationId: string, userId: string): Promise<ActionResult> {
const session = await requireAuth();
if (!session) return { success: false, error: "Non authentifié" };
const hasAccess = await requireEvaluationAccess(evaluationId, session.user.id, session.user.role === "admin");
if (!hasAccess) return { success: false, error: "Accès refusé" };
if (userId === session.user.id) return { success: false, error: "Vous avez déjà accès" };
const evaluation = await prisma.evaluation.findUnique({
where: { id: evaluationId },
select: { evaluatorId: true },
});
if (evaluation?.evaluatorId === userId) {
return { success: false, error: "L'évaluateur a déjà accès" };
}
try {
await prisma.evaluationShare.upsert({
where: { evaluationId_userId: { evaluationId, userId } },
create: { evaluationId, userId },
update: {},
});
revalidatePath(`/evaluations/${evaluationId}`);
return { success: true };
} catch (e) {
console.error(e);
return { success: false, error: "Erreur" };
}
}
export async function removeShare(evaluationId: string, userId: string): Promise<ActionResult> {
const session = await requireAuth();
if (!session) return { success: false, error: "Non authentifié" };
const hasAccess = await requireEvaluationAccess(evaluationId, session.user.id, session.user.role === "admin");
if (!hasAccess) return { success: false, error: "Accès refusé" };
try {
await prisma.evaluationShare.deleteMany({
where: { evaluationId, userId },
});
revalidatePath(`/evaluations/${evaluationId}`);
return { success: true };
} catch (e) {
console.error(e);
return { success: false, error: "Erreur" };
}
}

View File

@@ -1,66 +1,10 @@
"use client";
import { redirect } from "next/navigation";
import { getTemplates, getAdminUsers } from "@/lib/server-data";
import { AdminClient } from "@/components/AdminClient";
import { useState, useEffect } from "react";
export default async function AdminPage() {
const [templates, users] = await Promise.all([getTemplates(), getAdminUsers()]);
if (!users) redirect("/auth/login");
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>
);
return <AdminClient templates={templates} users={users} />;
}

View File

@@ -0,0 +1,3 @@
import { handlers } from "@/auth";
export const { GET, POST } = handlers;

View File

@@ -5,7 +5,7 @@ import { NextRequest, NextResponse } from "next/server";
* In production: use NextAuth, Clerk, or Supabase Auth
*/
const MOCK_ADMIN = {
email: "admin@cars-front.local",
email: "admin@peaksys.local",
name: "Admin User",
role: "admin",
};

View File

@@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import bcrypt from "bcryptjs";
export async function POST(req: NextRequest) {
try {
const { email, password, name } = await req.json();
if (!email || !password) {
return NextResponse.json(
{ error: "Email et mot de passe requis" },
{ status: 400 }
);
}
const existing = await prisma.user.findUnique({
where: { email: String(email).toLowerCase().trim() },
});
if (existing) {
return NextResponse.json(
{ error: "Un compte existe déjà avec cet email" },
{ status: 409 }
);
}
const passwordHash = await bcrypt.hash(String(password), 10);
await prisma.user.create({
data: {
email: String(email).toLowerCase().trim(),
passwordHash,
name: name?.trim() || null,
},
});
return NextResponse.json({ ok: true });
} catch (e) {
console.error("Signup error:", e);
return NextResponse.json(
{ error: "Erreur lors de l'inscription" },
{ status: 500 }
);
}
}

View File

@@ -1,179 +0,0 @@
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, candidateTeam, 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 (candidateTeam !== undefined) updateData.candidateTeam = candidateTeam || null;
if (evaluatorName != null) updateData.evaluatorName = evaluatorName;
if (evaluationDate != null) {
const d = new Date(evaluationDate);
if (!isNaN(d.getTime())) updateData.evaluationDate = d;
}
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),
},
});
}
if (Object.keys(updateData).length > 0) {
await prisma.evaluation.update({
where: { id },
data: updateData as Record<string, unknown>,
});
}
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);
const msg = e instanceof Error ? e.message : "Failed to update evaluation";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
export async function DELETE(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
await prisma.evaluation.delete({ where: { id } });
return NextResponse.json({ ok: true });
} catch (e) {
console.error(e);
return NextResponse.json({ error: "Failed to delete evaluation" }, { status: 500 });
}
}

View File

@@ -1,88 +0,0 @@
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, candidateTeam, 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,
candidateTeam: candidateTeam || null,
evaluatorName,
evaluationDate: new Date(evaluationDate),
templateId,
status: "draft",
},
include: {
template: true,
dimensionScores: { include: { dimension: true } },
},
});
// Create empty dimension scores for each template dimension
for (const dim of template.dimensions) {
await prisma.dimensionScore.create({
data: {
evaluationId: evaluation.id,
dimensionId: dim.id,
},
});
}
const updated = await prisma.evaluation.findUnique({
where: { id: evaluation.id },
include: {
template: true,
dimensionScores: { include: { dimension: true } },
},
});
return NextResponse.json(updated);
} catch (e) {
console.error(e);
return NextResponse.json({ error: "Failed to create evaluation" }, { status: 500 });
}
}

View File

@@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { evaluationToConfluenceMarkup } 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 markup = evaluationToConfluenceMarkup(
evaluation as Parameters<typeof evaluationToConfluenceMarkup>[0]
);
return new NextResponse(markup, {
headers: {
"Content-Type": "text/plain; charset=utf-8",
"Content-Disposition": `attachment; filename="guide-entretien-${id}.md"`,
},
});
} catch (e) {
console.error(e);
return NextResponse.json({ error: "Export failed" }, { status: 500 });
}
}

View File

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

View File

@@ -1,29 +0,0 @@
import { NextResponse } from "next/server";
import { Prisma } from "@prisma/client";
import { prisma } from "@/lib/db";
export async function GET() {
try {
const templates = await prisma.template.findMany({
include: {
dimensions: { orderBy: { orderIndex: "asc" } },
},
});
// Prisma ORM omits suggestedQuestions — enrich via raw
const dimsRaw = (await prisma.$queryRaw(
Prisma.sql`SELECT id, "templateId", slug, title, rubric, "orderIndex", "suggestedQuestions" FROM "TemplateDimension" ORDER BY "templateId", "orderIndex"`
)) as { id: string; templateId: string; slug: string; title: string; rubric: string; orderIndex: number; suggestedQuestions: string | null }[];
const dimMap = new Map(dimsRaw.map((d) => [d.id, d]));
const out = templates.map((t) => ({
...t,
dimensions: t.dimensions.map((d) => ({
...d,
suggestedQuestions: dimMap.get(d.id)?.suggestedQuestions ?? d.suggestedQuestions,
})),
}));
return NextResponse.json(out);
} catch (e) {
console.error(e);
return NextResponse.json({ error: "Failed to fetch templates" }, { status: 500 });
}
}

View File

@@ -0,0 +1,85 @@
"use client";
import { useState } from "react";
import { signIn } from "next-auth/react";
import Link from "next/link";
export default function LoginPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
setLoading(true);
try {
const res = await signIn("credentials", {
email,
password,
redirect: false,
});
if (res?.error) {
setError("Email ou mot de passe incorrect");
return;
}
window.location.href = "/dashboard";
} catch {
setError("Erreur de connexion");
} finally {
setLoading(false);
}
}
return (
<div className="mx-auto max-w-sm">
<h1 className="mb-6 font-mono text-lg font-medium text-zinc-800 dark:text-zinc-100">
Connexion
</h1>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="mb-1 block font-mono text-xs text-zinc-600 dark:text-zinc-400">
Email
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full rounded border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-800 px-3 py-2 text-sm text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500/30"
placeholder="vous@example.com"
/>
</div>
<div>
<label className="mb-1 block font-mono text-xs text-zinc-600 dark:text-zinc-400">
Mot de passe
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
className="w-full rounded border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-800 px-3 py-2 text-sm text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500/30"
/>
</div>
{error && (
<p className="font-mono text-xs text-red-500">{error}</p>
)}
<button
type="submit"
disabled={loading}
className="w-full rounded border border-cyan-500/50 bg-cyan-500/10 px-3 py-2 font-mono text-sm text-cyan-600 dark:text-cyan-400 hover:bg-cyan-500/20 disabled:opacity-50"
>
{loading ? "..." : "Se connecter"}
</button>
</form>
<p className="mt-4 font-mono text-xs text-zinc-500 dark:text-zinc-400">
Pas de compte ?{" "}
<Link href="/auth/signup" className="text-cyan-600 dark:text-cyan-400 hover:underline">
S&apos;inscrire
</Link>
</p>
</div>
);
}

View File

@@ -0,0 +1,109 @@
"use client";
import { useState } from "react";
import { signIn } from "next-auth/react";
import Link from "next/link";
export default function SignupPage() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [name, setName] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
setLoading(true);
try {
const res = await fetch("/api/auth/signup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password, name: name || undefined }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) {
setError(data.error ?? "Erreur lors de l'inscription");
return;
}
const signInRes = await signIn("credentials", {
email,
password,
redirect: false,
});
if (signInRes?.error) {
setError("Compte créé mais connexion échouée. Essayez de vous connecter.");
return;
}
window.location.href = "/";
} catch {
setError("Erreur lors de l'inscription");
} finally {
setLoading(false);
}
}
return (
<div className="mx-auto max-w-sm">
<h1 className="mb-6 font-mono text-lg font-medium text-zinc-800 dark:text-zinc-100">
Inscription
</h1>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="mb-1 block font-mono text-xs text-zinc-600 dark:text-zinc-400">
Email
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
className="w-full rounded border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-800 px-3 py-2 text-sm text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500/30"
placeholder="vous@example.com"
/>
</div>
<div>
<label className="mb-1 block font-mono text-xs text-zinc-600 dark:text-zinc-400">
Nom (optionnel)
</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
className="w-full rounded border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-800 px-3 py-2 text-sm text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500/30"
placeholder="Jean Dupont"
/>
</div>
<div>
<label className="mb-1 block font-mono text-xs text-zinc-600 dark:text-zinc-400">
Mot de passe
</label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={6}
className="w-full rounded border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-800 px-3 py-2 text-sm text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500/30"
/>
</div>
{error && (
<p className="font-mono text-xs text-red-500">{error}</p>
)}
<button
type="submit"
disabled={loading}
className="w-full rounded border border-cyan-500/50 bg-cyan-500/10 px-3 py-2 font-mono text-sm text-cyan-600 dark:text-cyan-400 hover:bg-cyan-500/20 disabled:opacity-50"
>
{loading ? "..." : "S'inscrire"}
</button>
</form>
<p className="mt-4 font-mono text-xs text-zinc-500 dark:text-zinc-400">
Déjà un compte ?{" "}
<Link href="/auth/login" className="text-cyan-600 dark:text-cyan-400 hover:underline">
Se connecter
</Link>
</p>
</div>
);
}

View File

@@ -0,0 +1,10 @@
import { redirect } from "next/navigation";
import { getEvaluations } from "@/lib/server-data";
import { DashboardClient } from "@/components/DashboardClient";
export default async function DashboardPage() {
const evaluations = await getEvaluations();
if (!evaluations) redirect("/auth/login");
return <DashboardClient evaluations={evaluations} />;
}

View File

@@ -1,395 +1,48 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import Link from "next/link";
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";
import { redirect } from "next/navigation";
import { getEvaluation, getTemplates, getUsers } from "@/lib/server-data";
import { EvaluationEditor } from "@/components/EvaluationEditor";
interface Dimension {
id: string;
slug: string;
title: string;
rubric: string;
suggestedQuestions?: string | null;
interface PageProps {
params: Promise<{ id: string }>;
}
interface DimensionScore {
id: string;
dimensionId: string;
score: number | null;
justification: string | null;
examplesObserved: string | null;
confidence: string | null;
candidateNotes: string | null;
dimension: Dimension;
}
export default async function EvaluationDetailPage({ params }: PageProps) {
const { id } = await params;
const [evaluation, templates, users] = await Promise.all([
getEvaluation(id),
getTemplates(),
getUsers(),
]);
interface Evaluation {
id: string;
candidateName: string;
candidateRole: string;
candidateTeam?: string | null;
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; suggestedQuestions?: string | null }) => [d.id, d]));
evalData.template.dimensions = evalData.template.dimensions.map((d: { id: string; suggestedQuestions?: string | null }) => ({
...d,
suggestedQuestions: d.suggestedQuestions ?? (dimMap.get(d.id) as { suggestedQuestions?: string | null } | undefined)?.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 existing = e.dimensionScores.find((ds) => ds.dimensionId === dimensionId);
const dim = e.template?.dimensions?.find((d) => d.id === dimensionId);
const scores = existing
? e.dimensionScores.map((ds) =>
ds.dimensionId === dimensionId ? { ...ds, ...data } : ds
)
: [
...e.dimensionScores,
{
id: `temp-${dimensionId}`,
dimensionId,
score: (data as { score?: number }).score ?? null,
justification: (data as { justification?: string }).justification ?? null,
examplesObserved: (data as { examplesObserved?: string }).examplesObserved ?? null,
confidence: (data as { confidence?: string }).confidence ?? null,
candidateNotes: (data as { candidateNotes?: string }).candidateNotes ?? null,
dimension: dim ?? { id: dimensionId, slug: "", title: "", rubric: "" },
},
];
const next = { ...e, dimensionScores: scores };
if (data.score !== undefined) {
setTimeout(() => handleSave(next, { skipRefresh: true }), 0);
}
return next;
});
};
const handleSave = async (evalOverride?: Evaluation | null, options?: { skipRefresh?: boolean }) => {
const toSave = evalOverride ?? evaluation;
if (!toSave) return;
setSaving(true);
try {
const res = await fetch(`/api/evaluations/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
candidateName: toSave.candidateName,
candidateRole: toSave.candidateRole,
candidateTeam: toSave.candidateTeam ?? null,
evaluatorName: toSave.evaluatorName,
evaluationDate: typeof toSave.evaluationDate === "string" ? toSave.evaluationDate : new Date(toSave.evaluationDate).toISOString(),
status: toSave.status,
findings: toSave.findings,
recommendations: toSave.recommendations,
dimensionScores: (toSave.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) {
if (!options?.skipRefresh) fetchEval();
} else {
const data = await res.json().catch(() => ({}));
alert(data.error ?? `Save failed (${res.status})`);
}
} catch (err) {
console.error("Save error:", err);
alert("Erreur lors de la sauvegarde");
} 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.{" "}
<Link href="/" className="text-cyan-600 dark:text-cyan-400 hover:underline">
<Link href="/dashboard" className="text-cyan-600 dark:text-cyan-400 hover:underline">
dashboard
</Link>
</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);
if (!users) redirect("/auth/login");
const templatesForEditor = templates.map((t) => ({
id: t.id,
name: t.name,
dimensions: t.dimensions.map((d) => ({
id: d.id,
suggestedQuestions: d.suggestedQuestions,
})),
}));
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}
{evaluation.candidateTeam && (
<span className="text-zinc-500"> ({evaluation.candidateTeam})</span>
)}
<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}
candidateTeam={evaluation.candidateTeam ?? ""}
evaluatorName={evaluation.evaluatorName}
evaluationDate={evaluation.evaluationDate.split("T")[0]}
templateId={evaluation.templateId}
templates={templates}
onChange={handleFormChange}
templateDisabled
<EvaluationEditor
id={id}
initialEvaluation={evaluation as unknown as Parameters<typeof EvaluationEditor>[0]["initialEvaluation"]}
templates={templatesForEditor}
users={users}
/>
</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}
evaluationId={id}
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={() => {
const updated = evaluation ? { ...evaluation, status: "submitted" } : null;
setEvaluation(updated);
if (updated) handleSave(updated);
}}
className="rounded border border-emerald-500/50 bg-emerald-500/20 px-3 py-1.5 font-mono text-xs text-emerald-600 dark:text-emerald-400 hover:bg-emerald-500/30"
>
soumettre
</button>
<button onClick={() => router.push("/")} className="rounded border border-zinc-300 dark:border-zinc-600 px-3 py-1.5 font-mono text-xs text-zinc-600 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700">
dashboard
</button>
<button
type="button"
onClick={() => setDeleteConfirmOpen(true)}
className="rounded border border-red-500/30 px-3 py-1.5 font-mono text-xs text-red-600 dark:text-red-400 hover:bg-red-500/10"
>
supprimer
</button>
</div>
<ExportModal
isOpen={exportOpen}
onClose={() => setExportOpen(false)}
evaluationId={id}
/>
<ConfirmModal
isOpen={deleteConfirmOpen}
title="Supprimer l'évaluation"
message={`Supprimer l'évaluation de ${evaluation.candidateName} ? Cette action est irréversible.`}
confirmLabel="Supprimer"
cancelLabel="Annuler"
variant="danger"
onConfirm={async () => {
const res = await fetch(`/api/evaluations/${id}`, { method: "DELETE" });
if (res.ok) router.push("/");
else alert("Erreur lors de la suppression");
}}
onCancel={() => setDeleteConfirmOpen(false)}
/>
</div>
);
}

View File

@@ -1,93 +1,24 @@
"use client";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { prisma } from "@/lib/db";
import { getTemplates } from "@/lib/server-data";
import { NewEvaluationForm } from "@/components/NewEvaluationForm";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { CandidateForm } from "@/components/CandidateForm";
export default async function NewEvaluationPage() {
const [session, templates] = await Promise.all([auth(), getTemplates()]);
if (!session?.user?.id) redirect("/auth/login");
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: "",
candidateTeam: "",
evaluatorName: "",
evaluationDate: new Date().toISOString().split("T")[0],
templateId: "",
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { name: true, email: true },
});
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));
// eslint-disable-next-line react-hooks/exhaustive-deps -- run once on mount to fetch templates and set initial templateId
}, []);
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>;
}
const initialEvaluatorName = user?.name || user?.email || "";
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 }))}
<NewEvaluationForm
templates={templates.map((t) => ({ id: t.id, name: t.name }))}
initialEvaluatorName={initialEvaluatorName}
/>
</div>
<div className="flex gap-2">
<button
type="submit"
disabled={saving || !form.templateId}
className="rounded border border-cyan-500/50 bg-cyan-500/20 px-4 py-2 font-mono text-sm text-cyan-600 dark:text-cyan-400 hover:bg-cyan-500/30 disabled:opacity-50"
>
{saving ? "..." : "créer →"}
</button>
<button
type="button"
onClick={() => router.back()}
className="rounded border border-zinc-300 dark:border-zinc-700 px-4 py-2 font-mono text-sm text-zinc-600 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800"
>
annuler
</button>
</div>
</form>
</div>
);
}

View File

@@ -25,3 +25,14 @@ input:focus, select:focus, textarea:focus {
outline: none;
ring: 2px;
}
@keyframes check {
0% { stroke-dashoffset: 20; opacity: 0; }
50% { opacity: 1; }
100% { stroke-dashoffset: 0; opacity: 1; }
}
.check-icon polyline {
stroke-dasharray: 20;
animation: check 0.3s ease-out forwards;
}

View File

@@ -1,7 +1,9 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { auth } from "@/auth";
import { Header } from "@/components/Header";
import { ThemeProvider } from "@/components/ThemeProvider";
import { SessionProvider } from "@/components/SessionProvider";
import "./globals.css";
const geistSans = Geist({
@@ -16,21 +18,30 @@ const geistMono = Geist_Mono({
export const metadata: Metadata = {
title: "Évaluateur Maturité IA Gen",
description: "Équipe Cars Front - Outil d'évaluation de la maturité IA Gen",
description: "Outil d'évaluation de la maturité IA Gen par Peaksys",
icons: {
icon: "/iconfull.png",
shortcut: "/iconfull.png",
apple: "/iconfull.png",
},
manifest: "/manifest.json",
};
export default function RootLayout({
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
const session = await auth();
return (
<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`}>
<SessionProvider>
<ThemeProvider>
<Header />
<main className="mx-auto max-w-5xl px-4 py-6">{children}</main>
<Header session={session} />
<main className="mx-auto max-w-7xl px-4 py-6">{children}</main>
</ThemeProvider>
</SessionProvider>
</body>
</html>
);

View File

@@ -1,140 +1,69 @@
"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;
candidateTeam?: string | null;
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 LandingPage() {
return (
<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>
<div className="mx-auto max-w-3xl">
<div className="py-16 md:py-24">
<h1 className="font-mono text-3xl font-semibold tracking-tight text-zinc-900 dark:text-zinc-50 md:text-4xl">
Évaluez la maturité IA/GenAI de vos candidats
</h1>
<p className="mt-6 font-mono text-base leading-relaxed text-zinc-600 dark:text-zinc-400">
Grilles structurées, guide d&apos;entretien, rubriques 15, questions de relance.
Un outil pensé pour standardiser vos évaluations et générer synthèses et recommandations.
</p>
<div className="mt-10 flex flex-wrap gap-4">
<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"
href="/auth/login"
className="rounded-lg border border-cyan-500 bg-cyan-500 px-5 py-2.5 font-mono text-sm font-medium text-white hover:bg-cyan-600 dark:border-cyan-400 dark:bg-cyan-500/90 dark:text-zinc-900 dark:hover:bg-cyan-400 transition-colors"
>
+ nouvelle
Accéder à l&apos;app
</Link>
<Link
href="/auth/signup"
className="rounded-lg border border-zinc-300 dark:border-zinc-600 px-5 py-2.5 font-mono text-sm text-zinc-700 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-800 transition-colors"
>
Créer un compte
</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">Équipe</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={8} 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={8} 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.candidateTeam ?? "—"}</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)}
/>
<section className="border-t border-zinc-200 dark:border-zinc-700 py-12">
<h2 className="font-mono text-lg font-medium text-zinc-800 dark:text-zinc-200">
Fonctionnalités
</h2>
<ul className="mt-6 grid gap-6 sm:grid-cols-2">
<li className="rounded-lg border border-zinc-200 dark:border-zinc-600 bg-white dark:bg-zinc-800/50 p-4">
<span className="font-mono text-sm font-medium text-cyan-600 dark:text-cyan-400">Templates</span>
<p className="mt-1 font-mono text-xs text-zinc-600 dark:text-zinc-400">
Grilles multi-dimensions (Full 15, Short 8) avec rubriques et signaux par niveau.
</p>
</li>
<li className="rounded-lg border border-zinc-200 dark:border-zinc-600 bg-white dark:bg-zinc-800/50 p-4">
<span className="font-mono text-sm font-medium text-cyan-600 dark:text-cyan-400">Guide d&apos;entretien</span>
<p className="mt-1 font-mono text-xs text-zinc-600 dark:text-zinc-400">
Questions suggérées, relances IA, notation 15 avec justification et exemples.
</p>
</li>
<li className="rounded-lg border border-zinc-200 dark:border-zinc-600 bg-white dark:bg-zinc-800/50 p-4">
<span className="font-mono text-sm font-medium text-cyan-600 dark:text-cyan-400">Radar & synthèse</span>
<p className="mt-1 font-mono text-xs text-zinc-600 dark:text-zinc-400">
Visualisation radar, findings et recommandations générés automatiquement.
</p>
</li>
<li className="rounded-lg border border-zinc-200 dark:border-zinc-600 bg-white dark:bg-zinc-800/50 p-4">
<span className="font-mono text-sm font-medium text-cyan-600 dark:text-cyan-400">Export</span>
<p className="mt-1 font-mono text-xs text-zinc-600 dark:text-zinc-400">
Export PDF et CSV pour partage et archivage.
</p>
</li>
</ul>
</section>
<div className="border-t border-zinc-200 dark:border-zinc-700 py-8 text-center">
<p className="font-mono text-xs text-zinc-500 dark:text-zinc-400">
IA Gen Maturity Evaluator · Peaksys
</p>
</div>
</div>
);
}

16
src/app/settings/page.tsx Normal file
View File

@@ -0,0 +1,16 @@
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { prisma } from "@/lib/db";
import { SettingsPasswordForm } from "@/components/SettingsPasswordForm";
export default async function SettingsPage() {
const session = await auth();
if (!session?.user?.id) redirect("/auth/login");
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { name: true },
});
return <SettingsPasswordForm currentName={user?.name ?? ""} />;
}

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

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

59
src/auth.ts Normal file
View File

@@ -0,0 +1,59 @@
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
import { prisma } from "@/lib/db";
import bcrypt from "bcryptjs";
export const { handlers, auth, signIn, signOut } = NextAuth({
trustHost: true,
providers: [
Credentials({
name: "credentials",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Mot de passe", type: "password" },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) return null;
const user = await prisma.user.findUnique({
where: { email: String(credentials.email) },
});
if (!user?.passwordHash) return null;
const ok = await bcrypt.compare(String(credentials.password), user.passwordHash);
if (!ok) return null;
return {
id: user.id,
email: user.email,
name: user.name,
role: user.role,
};
},
}),
],
pages: {
signIn: "/auth/login",
},
callbacks: {
async jwt({ token, user }) {
if (user) {
token.id = user.id;
token.email = user.email;
token.role = (user as { role?: string }).role;
} else if (token.id && !token.role) {
const u = await prisma.user.findUnique({
where: { id: token.id as string },
select: { role: true },
});
token.role = u?.role;
}
return token;
},
session({ session, token }) {
if (session.user) {
session.user.id = token.id as string;
session.user.role = token.role as string;
}
return session;
},
},
session: { strategy: "jwt", maxAge: 30 * 24 * 60 * 60 },
});

View File

@@ -0,0 +1,174 @@
"use client";
import { useState } from "react";
import { format } from "date-fns";
import { setUserRole, deleteUser } from "@/actions/admin";
import { useSession } from "next-auth/react";
import { ConfirmModal } from "@/components/ConfirmModal";
interface Template {
id: string;
name: string;
dimensions: { id: string; title: string; orderIndex: number }[];
}
interface User {
id: string;
email: string;
name: string | null;
role: string;
createdAt: Date | string;
}
interface AdminClientProps {
templates: Template[];
users: User[];
}
export function AdminClient({ templates, users: initialUsers }: AdminClientProps) {
const { data: session } = useSession();
const [users, setUsers] = useState(initialUsers);
const [updatingId, setUpdatingId] = useState<string | null>(null);
const [deleteTarget, setDeleteTarget] = useState<User | null>(null);
async function handleSetRole(userId: string, role: "admin" | "evaluator") {
setUpdatingId(userId);
try {
const result = await setUserRole(userId, role);
if (result.success) {
setUsers((prev) => prev.map((u) => (u.id === userId ? { ...u, role } : u)));
} else {
alert(result.error);
}
} finally {
setUpdatingId(null);
}
}
return (
<div>
<h1 className="mb-6 font-mono text-lg font-medium text-zinc-800 dark:text-zinc-200">Admin</h1>
<section>
<h2 className="mb-4 font-mono text-xs text-zinc-600 dark:text-zinc-500">Users</h2>
<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">Email</th>
<th className="px-4 py-2.5 text-left font-mono text-xs text-zinc-600 dark:text-zinc-400">Nom</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">Créé le</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>
{users.map((u) => (
<tr key={u.id} className="border-b border-zinc-200 dark:border-zinc-600/50 last:border-0">
<td className="px-4 py-2.5 text-sm text-zinc-800 dark:text-zinc-200">{u.email}</td>
<td className="px-4 py-2.5 text-sm text-zinc-600 dark:text-zinc-400">{u.name ?? "—"}</td>
<td className="px-4 py-2.5">
<span
className={`font-mono text-xs px-1.5 py-0.5 rounded ${
u.role === "admin" ? "bg-cyan-500/20 text-cyan-600 dark:text-cyan-400" : "bg-zinc-200 dark:bg-zinc-700 text-zinc-600 dark:text-zinc-400"
}`}
>
{u.role}
</span>
</td>
<td className="px-4 py-2.5 font-mono text-xs text-zinc-500 dark:text-zinc-400">
{format(new Date(u.createdAt), "yyyy-MM-dd HH:mm")}
</td>
<td className="px-4 py-2.5 text-right">
<span className="inline-flex items-center gap-2">
{u.role === "admin" ? (
<button
type="button"
onClick={() => handleSetRole(u.id, "evaluator")}
disabled={updatingId === u.id}
className="font-mono text-xs text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300 disabled:opacity-50"
title="Rétrograder en évaluateur"
>
{updatingId === u.id ? "..." : "rétrograder"}
</button>
) : (
<button
type="button"
onClick={() => handleSetRole(u.id, "admin")}
disabled={updatingId === u.id}
className="font-mono text-xs text-cyan-600 dark:text-cyan-400 hover:text-cyan-500 disabled:opacity-50"
title="Promouvoir admin"
>
{updatingId === u.id ? "..." : "promouvoir admin"}
</button>
)}
{u.id !== session?.user?.id && (
<button
type="button"
onClick={() => setDeleteTarget(u)}
className="font-mono text-xs text-red-500 hover:text-red-400"
title="Supprimer"
>
supprimer
</button>
)}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
<ConfirmModal
isOpen={!!deleteTarget}
title="Supprimer l'utilisateur"
message={
deleteTarget
? `Supprimer ${deleteTarget.name || deleteTarget.email} ? Les évaluations créées par cet utilisateur resteront (évaluateur mis à null).`
: ""
}
confirmLabel="Supprimer"
cancelLabel="Annuler"
variant="danger"
onConfirm={async () => {
if (!deleteTarget) return;
const result = await deleteUser(deleteTarget.id);
if (result.success) {
setUsers((prev) => prev.filter((u) => u.id !== deleteTarget.id));
setDeleteTarget(null);
} else {
alert(result.error);
}
}}
onCancel={() => setDeleteTarget(null)}
/>
<section className="mt-8">
<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>
</div>
);
}

View File

@@ -1,6 +1,10 @@
"use client";
import { useState } from "react";
import { updateEvaluation } from "@/actions/evaluations";
interface CandidateFormProps {
evaluationId?: string;
candidateName: string;
candidateRole: string;
candidateTeam?: string;
@@ -14,11 +18,14 @@ interface CandidateFormProps {
}
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";
"w-full h-10 rounded-lg border border-zinc-200 dark:border-zinc-600 bg-white dark:bg-zinc-700/60 px-3 py-2 text-sm text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 dark:placeholder-zinc-500 focus:border-cyan-500 focus:ring-2 focus:ring-cyan-500/20 transition-all box-border";
const labelClass = "mb-0.5 block text-xs font-medium text-zinc-600 dark:text-zinc-400";
const labelClass = "mb-1 block text-xs font-medium text-zinc-500 dark:text-zinc-400";
const savedStyle = { borderColor: "#a855f7", boxShadow: "0 0 0 1px #a855f733" };
export function CandidateForm({
evaluationId,
candidateName,
candidateRole,
candidateTeam = "",
@@ -30,68 +37,100 @@ export function CandidateForm({
disabled,
templateDisabled,
}: CandidateFormProps) {
const [dirtyFields, setDirtyFields] = useState<Record<string, boolean>>({});
const [savedField, setSavedField] = useState<string | null>(null);
const markDirty = (field: string, value: string) => {
setDirtyFields((p) => ({ ...p, [field]: true }));
setSavedField(null);
onChange(field, value);
};
const saveOnBlur = (field: string, value: string) => {
if (!dirtyFields[field] || !evaluationId) return;
setDirtyFields((p) => ({ ...p, [field]: false }));
setSavedField(field);
updateEvaluation(evaluationId, { [field]: value || null } as Parameters<typeof updateEvaluation>[1]);
setTimeout(() => setSavedField((cur) => (cur === field ? null : cur)), 800);
};
const fieldStyle = (field: string) => (savedField === field ? savedStyle : undefined);
return (
<div className="flex flex-wrap items-end gap-x-6 gap-y-3">
<div className="min-w-[140px]">
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
<div className="sm:col-span-2 lg:col-span-1">
<label className={labelClass}>Candidat</label>
<input
type="text"
value={candidateName}
onChange={(e) => onChange("candidateName", e.target.value)}
className={inputClass}
onChange={(e) => markDirty("candidateName", e.target.value)}
onBlur={(e) => saveOnBlur("candidateName", e.target.value)}
className={`${inputClass} transition-colors duration-200`}
style={fieldStyle("candidateName")}
disabled={disabled}
placeholder="Alice Chen"
/>
</div>
<div className="min-w-[140px]">
<div>
<label className={labelClass}>Rôle</label>
<input
type="text"
value={candidateRole}
onChange={(e) => onChange("candidateRole", e.target.value)}
className={inputClass}
onChange={(e) => markDirty("candidateRole", e.target.value)}
onBlur={(e) => saveOnBlur("candidateRole", e.target.value)}
className={`${inputClass} transition-colors duration-200`}
style={fieldStyle("candidateRole")}
disabled={disabled}
placeholder="ML Engineer"
/>
</div>
<div className="min-w-[140px]">
<div>
<label className={labelClass}>Équipe</label>
<input
type="text"
value={candidateTeam}
onChange={(e) => onChange("candidateTeam", e.target.value)}
className={inputClass}
onChange={(e) => markDirty("candidateTeam", e.target.value)}
onBlur={(e) => saveOnBlur("candidateTeam", e.target.value)}
className={`${inputClass} transition-colors duration-200`}
style={fieldStyle("candidateTeam")}
disabled={disabled}
placeholder="Cars Front"
placeholder="Peaksys"
/>
</div>
<div className="min-w-[120px]">
<div className="border-t border-zinc-200 dark:border-zinc-600 pt-4 sm:col-span-2 lg:col-span-3" />
<div>
<label className={labelClass}>Évaluateur</label>
<input
type="text"
value={evaluatorName}
onChange={(e) => onChange("evaluatorName", e.target.value)}
className={inputClass}
onChange={(e) => markDirty("evaluatorName", e.target.value)}
onBlur={(e) => saveOnBlur("evaluatorName", e.target.value)}
className={`${inputClass} transition-colors duration-200`}
style={fieldStyle("evaluatorName")}
disabled={disabled}
placeholder="Jean D."
/>
</div>
<div className="min-w-[120px]">
<div>
<label className={labelClass}>Date</label>
<input
type="date"
value={evaluationDate}
onChange={(e) => onChange("evaluationDate", e.target.value)}
className={inputClass}
onChange={(e) => markDirty("evaluationDate", e.target.value)}
onBlur={(e) => saveOnBlur("evaluationDate", e.target.value)}
className={`${inputClass} transition-colors duration-200`}
style={fieldStyle("evaluationDate")}
disabled={disabled}
/>
</div>
<div className="min-w-[160px]">
<div>
<label className={labelClass}>Modèle</label>
<select
value={templateId}
onChange={(e) => onChange("templateId", e.target.value)}
className={inputClass}
onChange={(e) => markDirty("templateId", e.target.value)}
onBlur={(e) => saveOnBlur("templateId", e.target.value)}
className={`${inputClass} transition-colors duration-200`}
style={fieldStyle("templateId")}
disabled={disabled || templateDisabled}
>
<option value=""></option>

View File

@@ -0,0 +1,361 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { deleteEvaluation } from "@/actions/evaluations";
import { format } from "date-fns";
import { ConfirmModal } from "@/components/ConfirmModal";
import { RadarChart } from "@/components/RadarChart";
interface Dimension {
id: string;
title: string;
}
interface EvalRow {
id: string;
candidateName: string;
candidateRole: string;
candidateTeam?: string | null;
evaluatorName: string;
evaluationDate: string;
template?: { name: string; dimensions?: Dimension[] };
status: string;
isPublic?: boolean;
dimensionScores?: { dimensionId: string; score: number | null; dimension?: { title: string } }[];
}
function buildRadarData(e: EvalRow) {
const dimensions = e.template?.dimensions ?? [];
const scoreMap = new Map(
(e.dimensionScores ?? []).map((ds) => [ds.dimensionId, ds])
);
return dimensions
.filter((dim) => !(dim.title ?? "").startsWith("[Optionnel]"))
.map((dim) => {
const ds = scoreMap.get(dim.id);
const score = ds?.score;
if (score == null) return null;
const s = Number(score);
if (Number.isNaN(s) || s < 0 || s > 5) return null;
const title = dim.title ?? "";
return {
dimension: title.length > 12 ? title.slice(0, 12) + "…" : title,
score: s,
fullMark: 5,
};
})
.filter((d): d is { dimension: string; score: number; fullMark: number } => d != null);
}
interface DashboardClientProps {
evaluations: EvalRow[];
}
type ViewMode = "full" | "team" | "table";
function groupByTeam(list: EvalRow[]): Map<string | null, EvalRow[]> {
const map = new Map<string | null, EvalRow[]>();
for (const e of list) {
const key = e.candidateTeam ?? null;
const arr = map.get(key) ?? [];
arr.push(e);
map.set(key, arr);
}
return map;
}
function EvalCard({
e,
onDelete,
}: {
e: EvalRow;
onDelete: (ev: React.MouseEvent) => void;
}) {
const radarData = buildRadarData(e);
return (
<Link
href={`/evaluations/${e.id}`}
className="group flex flex-col overflow-hidden rounded-lg border border-zinc-200 dark:border-zinc-600 bg-white dark:bg-zinc-800 shadow-sm dark:shadow-none hover:border-cyan-500/50 dark:hover:border-cyan-500/30 transition-colors"
>
<div className="flex flex-1 flex-col p-4">
<div className="mb-3 flex items-start justify-between gap-2">
<div className="min-w-0">
<h3 className="truncate font-medium text-zinc-800 dark:text-zinc-100">{e.candidateName}</h3>
<p className="truncate text-sm text-zinc-600 dark:text-zinc-400">
{e.candidateRole}
{e.candidateTeam && ` · ${e.candidateTeam}`}
</p>
</div>
<div className="flex shrink-0 items-center gap-1">
{!e.isPublic && (
<span className="font-mono text-xs px-1.5 py-0.5 rounded bg-purple-500/20 text-purple-600 dark:text-purple-400">
privé
</span>
)}
<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>
</div>
</div>
<div className="mb-3 flex flex-wrap gap-x-3 gap-y-0.5 font-mono text-xs text-zinc-500 dark:text-zinc-400">
<span>{e.evaluatorName}</span>
<span>{format(new Date(e.evaluationDate), "yyyy-MM-dd")}</span>
<span>{e.template?.name ?? ""}</span>
</div>
<div className="mt-auto min-h-[7rem]">
{radarData.length > 0 ? (
<RadarChart data={radarData} compact />
) : (
<div className="flex h-28 items-center justify-center rounded bg-zinc-50 dark:bg-zinc-700/30 font-mono text-xs text-zinc-400 dark:text-zinc-500">
pas de scores
</div>
)}
</div>
</div>
<div className="flex border-t border-zinc-200 dark:border-zinc-600 bg-zinc-50 dark:bg-zinc-700/30 px-4 py-2">
<span className="font-mono text-xs text-cyan-600 dark:text-cyan-400 group-hover:underline"> ouvrir</span>
<button
type="button"
onClick={onDelete}
className="ml-auto font-mono text-xs text-red-500 hover:text-red-400"
title="Supprimer"
>
supprimer
</button>
</div>
</Link>
);
}
export function DashboardClient({ evaluations }: DashboardClientProps) {
const [list, setList] = useState(evaluations);
const [deleteTarget, setDeleteTarget] = useState<EvalRow | null>(null);
const [viewMode, setViewMode] = useState<ViewMode>("full");
const grouped = viewMode === "team" ? groupByTeam(list) : null;
return (
<div>
<div className="mb-6 flex flex-wrap items-center justify-between gap-3">
<h1 className="font-mono text-lg font-medium text-zinc-800 dark:text-zinc-100">Évaluations</h1>
<div className="flex items-center gap-2">
<span className="font-mono text-xs text-zinc-500 dark:text-zinc-400">Vue :</span>
<div className="inline-flex rounded border border-zinc-200 dark:border-zinc-600 bg-zinc-50 dark:bg-zinc-800/50 p-0.5">
<button
type="button"
onClick={() => setViewMode("full")}
className={`rounded px-2.5 py-1 font-mono text-xs transition-colors ${
viewMode === "full"
? "bg-white dark:bg-zinc-700 text-zinc-800 dark:text-zinc-100 shadow-sm"
: "text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-300"
}`}
>
full
</button>
<button
type="button"
onClick={() => setViewMode("team")}
className={`rounded px-2.5 py-1 font-mono text-xs transition-colors ${
viewMode === "team"
? "bg-white dark:bg-zinc-700 text-zinc-800 dark:text-zinc-100 shadow-sm"
: "text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-300"
}`}
>
team
</button>
<button
type="button"
onClick={() => setViewMode("table")}
className={`rounded px-2.5 py-1 font-mono text-xs transition-colors ${
viewMode === "table"
? "bg-white dark:bg-zinc-700 text-zinc-800 dark:text-zinc-100 shadow-sm"
: "text-zinc-500 dark:text-zinc-400 hover:text-zinc-700 dark:hover:text-zinc-300"
}`}
>
tableau
</button>
</div>
</div>
<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>
{list.length === 0 ? (
<div className="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>
</div>
) : viewMode === "table" ? (
<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">
<div className="overflow-x-auto">
<table className="w-full min-w-[640px]">
<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 font-medium text-zinc-600 dark:text-zinc-400">
Candidat
</th>
<th className="px-4 py-2.5 text-left font-mono text-xs font-medium text-zinc-600 dark:text-zinc-400">
Rôle
</th>
<th className="px-4 py-2.5 text-left font-mono text-xs font-medium text-zinc-600 dark:text-zinc-400">
Équipe
</th>
<th className="px-4 py-2.5 text-left font-mono text-xs font-medium text-zinc-600 dark:text-zinc-400">
Évaluateur
</th>
<th className="px-4 py-2.5 text-left font-mono text-xs font-medium text-zinc-600 dark:text-zinc-400">
Date
</th>
<th className="px-4 py-2.5 text-left font-mono text-xs font-medium text-zinc-600 dark:text-zinc-400">
Template
</th>
<th className="px-4 py-2.5 text-left font-mono text-xs font-medium text-zinc-600 dark:text-zinc-400">
Statut
</th>
<th className="px-4 py-2.5 text-right font-mono text-xs font-medium text-zinc-600 dark:text-zinc-400">
Actions
</th>
</tr>
</thead>
<tbody>
{list.map((e) => (
<tr
key={e.id}
className="border-b border-zinc-200 dark:border-zinc-600/50 last:border-0 hover:bg-zinc-50 dark:hover:bg-zinc-700/30 transition-colors"
>
<td className="px-4 py-2.5">
<Link
href={`/evaluations/${e.id}`}
className="text-sm font-medium text-cyan-600 dark:text-cyan-400 hover:underline"
>
{e.candidateName}
</Link>
</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.candidateTeam ?? "—"}
</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={`inline-block 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-2 font-mono text-xs">
<Link
href={`/evaluations/${e.id}`}
className="text-cyan-600 dark:text-cyan-400 hover:underline"
>
ouvrir
</Link>
<button
type="button"
onClick={() => setDeleteTarget(e)}
className="text-red-500 hover:text-red-400"
title="Supprimer"
>
supprimer
</button>
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
) : viewMode === "team" && grouped ? (
<div className="space-y-6">
{Array.from(grouped.entries())
.sort(([a], [b]) => {
if (a == null) return 1;
if (b == null) return -1;
return a.localeCompare(b);
})
.map(([team, evals]) => (
<div key={team ?? "__none__"}>
<h2 className="mb-3 font-mono text-sm font-medium text-zinc-600 dark:text-zinc-400">
{team ?? "Sans équipe"}
</h2>
<div className="grid gap-4 [grid-template-columns:repeat(auto-fill,minmax(300px,1fr))]">
{evals.map((e) => (
<EvalCard
key={e.id}
e={e}
onDelete={(ev) => {
ev.preventDefault();
ev.stopPropagation();
setDeleteTarget(e);
}}
/>
))}
</div>
</div>
))}
</div>
) : (
<div className="grid gap-4 [grid-template-columns:repeat(auto-fill,minmax(300px,1fr))]">
{list.map((e) => (
<EvalCard
key={e.id}
e={e}
onDelete={(ev) => {
ev.preventDefault();
ev.stopPropagation();
setDeleteTarget(e);
}}
/>
))}
</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 result = await deleteEvaluation(deleteTarget.id);
if (result.success) setList((prev) => prev.filter((x) => x.id !== deleteTarget.id));
else alert(result.error);
}}
onCancel={() => setDeleteTarget(null)}
/>
</div>
);
}

View File

@@ -1,6 +1,8 @@
"use client";
import { useState, useEffect } from "react";
import { updateDimensionScore } from "@/actions/evaluations";
import { parseQuestions, parseRubric } from "@/lib/export-utils";
const STORAGE_KEY_PREFIX = "eval-dim-expanded";
@@ -51,43 +53,48 @@ interface DimensionCardProps {
index: number;
evaluationId?: string;
onScoreChange: (dimensionId: string, data: Partial<DimensionScore>) => void;
/** Increment to collapse this card (e.g. from "Tout fermer" button) */
collapseAllTrigger?: number;
}
function parseRubric(rubric: string): string[] {
if (rubric === "1-5" || !rubric) return ["1", "2", "3", "4", "5"];
const labels: string[] = [];
for (let i = 1; i <= 5; i++) {
const m = rubric.match(new RegExp(`${i}:([^;]+)`));
labels.push(m ? m[1].trim() : String(i));
}
return labels;
}
function parseQuestions(s: string | null | undefined): string[] {
if (!s) return [];
try {
const arr = JSON.parse(s) as unknown;
return Array.isArray(arr) ? arr.filter((x): x is string => typeof x === "string") : [];
} catch {
return [];
}
}
const inputClass =
"w-full rounded border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-700/80 px-2.5 py-1.5 text-sm text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 dark:placeholder-zinc-500 focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500/30";
export function DimensionCard({ dimension, score, index, evaluationId, onScoreChange }: DimensionCardProps) {
export function DimensionCard({ dimension, score, index, evaluationId, onScoreChange, collapseAllTrigger }: DimensionCardProps) {
const [notes, setNotes] = useState(score?.candidateNotes ?? "");
const [dirtyFields, setDirtyFields] = useState<Record<string, boolean>>({});
const [savedField, setSavedField] = useState<string | null>(null);
const markDirty = (field: string) => {
setDirtyFields((p) => ({ ...p, [field]: true }));
setSavedField(null);
};
const saveOnBlur = (field: string, data: Parameters<typeof updateDimensionScore>[2]) => {
if (!dirtyFields[field] || !evaluationId) return;
setDirtyFields((p) => ({ ...p, [field]: false }));
setSavedField(field);
updateDimensionScore(evaluationId, dimension.id, data);
setTimeout(() => setSavedField((cur) => (cur === field ? null : cur)), 800);
};
const savedStyle = { borderColor: "#a855f7", boxShadow: "0 0 0 1px #a855f733" };
const hasQuestions = parseQuestions(dimension.suggestedQuestions).length > 0;
const [expanded, setExpanded] = useState(hasQuestions);
useEffect(() => {
if (evaluationId && typeof window !== "undefined") {
const stored = getStoredExpanded(evaluationId, dimension.id);
if (stored !== null) setExpanded(stored);
if (stored !== null) queueMicrotask(() => setExpanded(stored));
}
}, [evaluationId, dimension.id]);
useEffect(() => {
if (collapseAllTrigger != null && collapseAllTrigger > 0) {
queueMicrotask(() => setExpanded(false));
if (evaluationId) setStoredExpanded(evaluationId, dimension.id, false);
}
}, [collapseAllTrigger, evaluationId, dimension.id]);
const toggleExpanded = () => {
setExpanded((e) => {
const next = !e;
@@ -182,10 +189,13 @@ export function DimensionCard({ dimension, score, index, evaluationId, onScoreCh
value={notes}
onChange={(e) => {
setNotes(e.target.value);
markDirty("notes");
onScoreChange(dimension.id, { candidateNotes: e.target.value });
}}
onBlur={() => saveOnBlur("notes", { candidateNotes: notes })}
rows={2}
className={inputClass}
className={`${inputClass} transition-colors duration-200`}
style={savedField === "notes" ? savedStyle : undefined}
placeholder="Réponses du candidat..."
/>
</div>
@@ -197,8 +207,13 @@ export function DimensionCard({ dimension, score, index, evaluationId, onScoreCh
<input
type="text"
value={score?.justification ?? ""}
onChange={(e) => onScoreChange(dimension.id, { justification: e.target.value || null })}
className={inputClass}
onChange={(e) => {
markDirty("justification");
onScoreChange(dimension.id, { justification: e.target.value || null });
}}
onBlur={(e) => saveOnBlur("justification", { justification: e.target.value || null })}
className={`${inputClass} transition-colors duration-200`}
style={savedField === "justification" ? savedStyle : undefined}
placeholder="Courte..."
/>
</div>
@@ -207,8 +222,13 @@ export function DimensionCard({ dimension, score, index, evaluationId, onScoreCh
<input
type="text"
value={score?.examplesObserved ?? ""}
onChange={(e) => onScoreChange(dimension.id, { examplesObserved: e.target.value || null })}
className={inputClass}
onChange={(e) => {
markDirty("examples");
onScoreChange(dimension.id, { examplesObserved: e.target.value || null });
}}
onBlur={(e) => saveOnBlur("examples", { examplesObserved: e.target.value || null })}
className={`${inputClass} transition-colors duration-200`}
style={savedField === "examples" ? savedStyle : undefined}
placeholder="Concrets..."
/>
</div>
@@ -216,8 +236,13 @@ export function DimensionCard({ dimension, score, index, evaluationId, onScoreCh
<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}
onChange={(e) => {
markDirty("confidence");
onScoreChange(dimension.id, { confidence: e.target.value || null });
}}
onBlur={(e) => saveOnBlur("confidence", { confidence: e.target.value || null })}
className={`${inputClass} transition-colors duration-200`}
style={savedField === "confidence" ? savedStyle : undefined}
>
<option value=""></option>
<option value="low">Faible</option>

View File

@@ -0,0 +1,421 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { useRouter } from "next/navigation";
import { updateEvaluation, deleteEvaluation, fetchEvaluation } from "@/actions/evaluations";
import { CandidateForm } from "@/components/CandidateForm";
import { DimensionCard } from "@/components/DimensionCard";
import { RadarChart } from "@/components/RadarChart";
import { ExportModal } from "@/components/ExportModal";
import { ShareModal } from "@/components/ShareModal";
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;
candidateTeam?: string | null;
evaluatorName: string;
evaluatorId?: string | null;
evaluationDate: string;
templateId: string;
template: { id: string; name: string; dimensions: Dimension[] };
status: string;
findings: string | null;
recommendations: string | null;
dimensionScores: DimensionScore[];
sharedWith?: { id: string; user: { id: string; email: string; name: string | null } }[];
isPublic?: boolean;
}
interface EvaluationEditorProps {
id: string;
initialEvaluation: Evaluation;
templates: { id: string; name: string; dimensions?: { id: string; suggestedQuestions?: string | null }[] }[];
users: { id: string; email: string; name: string | null }[];
}
export function EvaluationEditor({ id, initialEvaluation, templates, users }: EvaluationEditorProps) {
const router = useRouter();
const [evaluation, setEvaluation] = useState<Evaluation>(initialEvaluation);
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [exportOpen, setExportOpen] = useState(false);
const [shareOpen, setShareOpen] = useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [collapseAllTrigger, setCollapseAllTrigger] = useState(0);
const fetchEval = useCallback(async () => {
const result = await fetchEvaluation(id);
if (result.success && result.data) {
const d = result.data;
setEvaluation({ ...d, dimensionScores: d.dimensionScores ?? [] } as Evaluation);
}
}, [id]);
// Draft backup to localStorage (debounced)
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) => {
setEvaluation((e) => (e ? { ...e, [field]: value } : null!));
};
const handleScoreChange = (dimensionId: string, data: Partial<DimensionScore>) => {
setEvaluation((e) => {
if (!e) return null!;
const existing = e.dimensionScores.find((ds) => ds.dimensionId === dimensionId);
const dim = e.template?.dimensions?.find((d) => d.id === dimensionId);
const scores = existing
? e.dimensionScores.map((ds) =>
ds.dimensionId === dimensionId ? { ...ds, ...data } : ds
)
: [
...e.dimensionScores,
{
id: `temp-${dimensionId}`,
dimensionId,
score: (data as { score?: number }).score ?? null,
justification: (data as { justification?: string }).justification ?? null,
examplesObserved: (data as { examplesObserved?: string }).examplesObserved ?? null,
confidence: (data as { confidence?: string }).confidence ?? null,
candidateNotes: (data as { candidateNotes?: string }).candidateNotes ?? null,
dimension: dim ?? { id: dimensionId, slug: "", title: "", rubric: "" },
},
];
const next = { ...e, dimensionScores: scores };
if (data.score !== undefined) {
setTimeout(() => handleSave(next, { skipRefresh: true }), 0);
}
return next;
});
};
const handleSave = async (evalOverride?: Evaluation | null, options?: { skipRefresh?: boolean }) => {
const toSave = evalOverride ?? evaluation;
if (!toSave) return;
setSaving(true);
try {
const result = await updateEvaluation(id, {
candidateName: toSave.candidateName,
candidateRole: toSave.candidateRole,
candidateTeam: toSave.candidateTeam ?? null,
evaluatorName: toSave.evaluatorName,
evaluationDate: typeof toSave.evaluationDate === "string" ? toSave.evaluationDate : new Date(toSave.evaluationDate).toISOString(),
status: toSave.status,
findings: toSave.findings,
recommendations: toSave.recommendations,
isPublic: toSave.isPublic ?? false,
dimensionScores: (toSave.dimensionScores ?? []).map((ds) => ({
dimensionId: ds.dimensionId,
evaluationId: id,
score: ds.score,
justification: ds.justification,
examplesObserved: ds.examplesObserved,
confidence: ds.confidence,
candidateNotes: ds.candidateNotes,
})),
});
if (result.success) {
if (!options?.skipRefresh) fetchEval();
setSaved(true);
setTimeout(() => setSaved(false), 2000);
} else {
alert(result.error);
}
} catch (err) {
console.error("Save error:", err);
alert("Erreur lors de la sauvegarde");
} finally {
setSaving(false);
}
};
const handleGenerateFindings = () => {
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";
const dimensions = evaluation.template?.dimensions ?? [];
const dimensionScores = evaluation.dimensionScores ?? [];
const scoreMap = new Map(dimensionScores.map((ds) => [ds.dimensionId, ds]));
const radarData = dimensions
.filter((dim) => !(dim.title ?? "").startsWith("[Optionnel]"))
.map((dim) => {
const ds = scoreMap.get(dim.id);
const score = ds?.score;
if (score == null) return null;
const title = dim.title ?? "";
const s = Number(score);
if (Number.isNaN(s) || s < 0 || s > 5) return null;
return {
dimension: title.length > 12 ? title.slice(0, 12) + "…" : title,
score: s,
fullMark: 5,
};
})
.filter((d): d is { dimension: string; score: number; fullMark: number } => d != null);
const avgScore = computeAverageScore(dimensionScores);
const templatesForForm = templates.map((t) => ({ id: t.id, name: t.name }));
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}
{evaluation.candidateTeam && (
<span className="text-zinc-500"> ({evaluation.candidateTeam})</span>
)}
<span className="text-zinc-500"> / </span> {evaluation.candidateRole}
</h1>
<div className="flex gap-2">
<button
onClick={() => handleSave()}
disabled={saving}
className={`rounded border px-3 py-1.5 font-mono text-xs disabled:opacity-50 transition-all duration-300 flex items-center gap-1.5 ${
saved
? "border-purple-500/50 bg-purple-500/10 text-purple-600 dark:text-purple-400"
: "border-zinc-300 dark:border-zinc-600 bg-zinc-100 dark:bg-zinc-700 text-zinc-700 dark:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-zinc-600"
}`}
>
{saving ? (
<span className="inline-block h-3 w-3 animate-spin rounded-full border border-zinc-400 border-t-transparent" />
) : saved ? (
<svg className="check-icon h-3 w-3" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="1.5,6 4.5,9 10.5,3" />
</svg>
) : null}
{saving ? "saving" : saved ? "saved" : "save"}
</button>
<button
onClick={() => {
const next = !(evaluation.isPublic ?? false);
setEvaluation((e) => (e ? { ...e, isPublic: next } : null!));
handleSave(evaluation ? { ...evaluation, isPublic: next } : null);
}}
className={`rounded border px-3 py-1.5 font-mono text-xs ${
evaluation.isPublic
? "border-emerald-500/50 bg-emerald-500/20 text-emerald-600 dark:text-emerald-400"
: "border-zinc-300 dark:border-zinc-600 bg-zinc-100 dark:bg-zinc-700 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-200 dark:hover:bg-zinc-600"
}`}
>
{evaluation.isPublic ? "publique" : "rendre publique"}
</button>
<button
onClick={() => setShareOpen(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"
>
partager
</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="relative overflow-hidden rounded-xl border border-zinc-200 dark:border-zinc-600 bg-gradient-to-br from-zinc-50 to-white dark:from-zinc-800/80 dark:to-zinc-800 p-5 shadow-sm dark:shadow-none">
<div className="absolute left-0 top-0 h-full w-1 bg-gradient-to-b from-cyan-500/60 to-cyan-400/40" aria-hidden />
<h2 className="mb-4 font-mono text-xs font-medium uppercase tracking-wider text-zinc-500 dark:text-zinc-400">
Session
</h2>
<CandidateForm
evaluationId={id}
candidateName={evaluation.candidateName}
candidateRole={evaluation.candidateRole}
candidateTeam={evaluation.candidateTeam ?? ""}
evaluatorName={evaluation.evaluatorName}
evaluationDate={evaluation.evaluationDate.split("T")[0]}
templateId={evaluation.templateId}
templates={templatesForForm}
onChange={handleFormChange}
templateDisabled
/>
</section>
<section>
<div className="mb-3 flex items-center justify-between gap-2">
<h2 className="font-mono text-xs text-zinc-600 dark:text-zinc-500">Dimensions</h2>
<button
type="button"
onClick={() => setCollapseAllTrigger((c) => c + 1)}
className="font-mono text-xs text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300"
>
tout fermer
</button>
</div>
<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}
evaluationId={id}
score={scoreMap.get(dim.id) ?? null}
onScoreChange={handleScoreChange}
collapseAllTrigger={collapseAllTrigger}
/>
</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={() => {
const updated = { ...evaluation, status: "submitted" };
setEvaluation(updated);
handleSave(updated);
}}
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("/dashboard")} 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}
/>
<ShareModal
isOpen={shareOpen}
onClose={() => setShareOpen(false)}
evaluationId={id}
evaluatorId={evaluation.evaluatorId}
users={users}
sharedWith={evaluation.sharedWith ?? []}
onUpdate={fetchEval}
/>
<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 result = await deleteEvaluation(id);
if (result.success) router.push("/dashboard");
else alert(result.error);
}}
onCancel={() => setDeleteConfirmOpen(false)}
/>
</div>
);
}

View File

@@ -12,6 +12,7 @@ export function ExportModal({ isOpen, onClose, evaluationId }: ExportModalProps)
const base = typeof window !== "undefined" ? window.location.origin : "";
const csvUrl = `${base}/api/export/csv?id=${evaluationId}`;
const pdfUrl = `${base}/api/export/pdf?id=${evaluationId}`;
const confluenceUrl = `${base}/api/export/confluence?id=${evaluationId}`;
return (
<>
@@ -37,6 +38,14 @@ export function ExportModal({ isOpen, onClose, evaluationId }: ExportModalProps)
>
pdf
</a>
<a
href={confluenceUrl}
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"
title="Guide d'entretien (questions + grille) au format Confluence wiki markup"
>
confluence
</a>
</div>
<button
type="button"

View File

@@ -1,25 +1,66 @@
"use client";
import Link from "next/link";
import type { Session } from "next-auth";
import { ThemeToggle } from "./ThemeToggle";
import { SignOutButton } from "./SignOutButton";
export function Header() {
export function Header({ session }: { session: Session | null }) {
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">
<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">
{session ? (
<>
<Link
href="/dashboard"
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">
<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">
<Link
href="/templates"
className="text-zinc-500 hover:text-cyan-600 dark:text-zinc-400 dark:hover:text-cyan-400 transition-colors"
>
/templates
</Link>
{session.user.role === "admin" && (
<Link
href="/admin"
className="text-zinc-500 hover:text-cyan-600 dark:text-zinc-400 dark:hover:text-cyan-400 transition-colors"
>
/admin
</Link>
)}
<Link
href="/settings"
className="text-zinc-500 hover:text-cyan-600 dark:text-zinc-400 dark:hover:text-cyan-400 transition-colors"
>
/paramètres
</Link>
<span className="text-zinc-400 dark:text-zinc-500">
{session.user.email}
</span>
<SignOutButton />
</>
) : (
<Link
href="/auth/login"
className="text-zinc-500 hover:text-cyan-600 dark:text-zinc-400 dark:hover:text-cyan-400 transition-colors"
>
Se connecter
</Link>
)}
<ThemeToggle />
</nav>
</div>

View File

@@ -0,0 +1,75 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import { createEvaluation } from "@/actions/evaluations";
import { CandidateForm } from "@/components/CandidateForm";
interface NewEvaluationFormProps {
templates: { id: string; name: string }[];
initialEvaluatorName: string;
}
export function NewEvaluationForm({ templates, initialEvaluatorName }: NewEvaluationFormProps) {
const router = useRouter();
const [saving, setSaving] = useState(false);
const [form, setForm] = useState({
candidateName: "",
candidateRole: "",
candidateTeam: "",
evaluatorName: initialEvaluatorName,
evaluationDate: new Date().toISOString().split("T")[0],
templateId: templates[0]?.id ?? "",
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!form.templateId) return;
setSaving(true);
try {
const result = await createEvaluation({
...form,
evaluationDate: new Date(form.evaluationDate).toISOString(),
});
if (result.success && result.data) {
router.push(`/evaluations/${result.data.id}`);
} else if (!result.success) {
alert(result.error);
}
} finally {
setSaving(false);
}
};
return (
<div>
<h1 className="mb-6 font-mono text-lg font-medium text-zinc-800 dark:text-zinc-200">Nouvelle évaluation</h1>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="rounded-lg border border-zinc-200 dark:border-zinc-600 bg-white dark:bg-zinc-800 p-4 shadow-sm dark:shadow-none">
<h2 className="mb-3 font-mono text-xs text-zinc-600 dark:text-zinc-500">Session</h2>
<CandidateForm
{...form}
templates={templates}
onChange={(field, value) => setForm((f) => ({ ...f, [field]: value }))}
/>
</div>
<div className="flex gap-2">
<button
type="submit"
disabled={saving || !form.templateId}
className="rounded border border-cyan-500/50 bg-cyan-500/20 px-4 py-2 font-mono text-sm text-cyan-600 dark:text-cyan-400 hover:bg-cyan-500/30 disabled:opacity-50"
>
{saving ? "..." : "créer →"}
</button>
<button
type="button"
onClick={() => router.back()}
className="rounded border border-zinc-300 dark:border-zinc-700 px-4 py-2 font-mono text-sm text-zinc-600 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800"
>
annuler
</button>
</div>
</form>
</div>
);
}

View File

@@ -1,5 +1,6 @@
"use client";
import { useEffect, useState } from "react";
import {
Radar,
RadarChart as RechartsRadar,
@@ -20,6 +21,8 @@ interface DataPoint {
interface RadarChartProps {
data: DataPoint[];
/** Compact mode for cards (smaller, no legend) */
compact?: boolean;
}
const LIGHT = {
@@ -39,19 +42,30 @@ const DARK = {
tooltipText: "#fafafa",
};
export function RadarChart({ data }: RadarChartProps) {
export function RadarChart({ data, compact }: RadarChartProps) {
const { theme } = useTheme();
const [mounted, setMounted] = useState(false);
useEffect(() => {
// Defer chart until client so ResponsiveContainer can measure parent (avoids width/height -1 on SSR)
const raf = requestAnimationFrame(() => setMounted(true));
return () => cancelAnimationFrame(raf);
}, []);
const c = theme === "dark" ? DARK : LIGHT;
if (data.length === 0) return null;
// ResponsiveContainer needs real DOM dimensions; avoid -1 on SSR
if (!mounted) {
return <div className={compact ? "h-28 w-full" : "h-72 w-full"} aria-hidden />;
}
return (
<div className="h-72 w-full">
<div className={compact ? "h-28 w-full" : "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 }} />
<PolarAngleAxis dataKey="dimension" tick={{ fontSize: compact ? 7 : 9, fill: c.axis }} />
<PolarRadiusAxis angle={30} domain={[0, 5]} tick={false} />
<Radar
name="Score"
dataKey="score"
@@ -68,7 +82,7 @@ export function RadarChart({ data }: RadarChartProps) {
fontSize: "12px",
}}
/>
<Legend wrapperStyle={{ fontSize: "11px" }} />
{!compact && <Legend wrapperStyle={{ fontSize: "11px" }} />}
</RechartsRadar>
</ResponsiveContainer>
</div>

View File

@@ -0,0 +1,7 @@
"use client";
import { SessionProvider as NextAuthSessionProvider } from "next-auth/react";
export function SessionProvider({ children }: { children: React.ReactNode }) {
return <NextAuthSessionProvider>{children}</NextAuthSessionProvider>;
}

View File

@@ -0,0 +1,185 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { changePassword, changeName } from "@/actions/password";
export function SettingsPasswordForm({ currentName }: { currentName: string }) {
// --- Nom d'affichage ---
const [name, setName] = useState(currentName);
const [nameError, setNameError] = useState("");
const [nameSuccess, setNameSuccess] = useState(false);
const [nameLoading, setNameLoading] = useState(false);
async function handleNameSubmit(e: React.FormEvent) {
e.preventDefault();
setNameError("");
setNameSuccess(false);
setNameLoading(true);
try {
const result = await changeName(name);
if (result.success) {
setNameSuccess(true);
} else {
setNameError(result.error);
}
} catch {
setNameError("Erreur de connexion");
} finally {
setNameLoading(false);
}
}
// --- Mot de passe ---
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState("");
const [success, setSuccess] = useState(false);
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
setSuccess(false);
if (newPassword !== confirmPassword) {
setError("Les deux nouveaux mots de passe ne correspondent pas");
return;
}
if (newPassword.length < 8) {
setError("Le mot de passe doit faire au moins 8 caractères");
return;
}
setLoading(true);
try {
const result = await changePassword(currentPassword, newPassword);
if (result.success) {
setSuccess(true);
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
} else {
setError(result.error);
}
} catch {
setError("Erreur de connexion");
} finally {
setLoading(false);
}
}
return (
<div className="mx-auto max-w-md">
<h1 className="mb-6 font-mono text-lg font-medium text-zinc-800 dark:text-zinc-100">
Paramètres
</h1>
<section className="rounded-lg border border-zinc-200 dark:border-zinc-600 bg-white dark:bg-zinc-800/50 p-4">
<h2 className="mb-4 font-mono text-sm font-medium text-zinc-700 dark:text-zinc-300">
Nom d&apos;affichage
</h2>
<form onSubmit={handleNameSubmit} className="space-y-4">
<div>
<label className="mb-1 block font-mono text-xs text-zinc-600 dark:text-zinc-400">
Nom
</label>
<input
type="text"
value={name}
onChange={(e) => { setName(e.target.value); setNameSuccess(false); }}
required
maxLength={64}
autoComplete="name"
className="w-full rounded border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-800 px-3 py-2 text-sm text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500/30"
/>
</div>
{nameError && (
<p className="font-mono text-xs text-red-500">{nameError}</p>
)}
{nameSuccess && (
<p className="font-mono text-xs text-emerald-600 dark:text-emerald-400">
Nom modifié.
</p>
)}
<button
type="submit"
disabled={nameLoading}
className="rounded border border-cyan-500/50 bg-cyan-500/10 px-3 py-2 font-mono text-sm text-cyan-600 dark:text-cyan-400 hover:bg-cyan-500/20 disabled:opacity-50"
>
{nameLoading ? "..." : "Modifier le nom"}
</button>
</form>
</section>
<section className="mt-4 rounded-lg border border-zinc-200 dark:border-zinc-600 bg-white dark:bg-zinc-800/50 p-4">
<h2 className="mb-4 font-mono text-sm font-medium text-zinc-700 dark:text-zinc-300">
Changer mon mot de passe
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="mb-1 block font-mono text-xs text-zinc-600 dark:text-zinc-400">
Mot de passe actuel
</label>
<input
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
required
autoComplete="current-password"
className="w-full rounded border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-800 px-3 py-2 text-sm text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500/30"
/>
</div>
<div>
<label className="mb-1 block font-mono text-xs text-zinc-600 dark:text-zinc-400">
Nouveau mot de passe
</label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
minLength={8}
autoComplete="new-password"
className="w-full rounded border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-800 px-3 py-2 text-sm text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500/30"
/>
</div>
<div>
<label className="mb-1 block font-mono text-xs text-zinc-600 dark:text-zinc-400">
Confirmer le nouveau mot de passe
</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
minLength={8}
autoComplete="new-password"
className="w-full rounded border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-800 px-3 py-2 text-sm text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500/30"
/>
</div>
{error && (
<p className="font-mono text-xs text-red-500">{error}</p>
)}
{success && (
<p className="font-mono text-xs text-emerald-600 dark:text-emerald-400">
Mot de passe modifié.
</p>
)}
<button
type="submit"
disabled={loading}
className="rounded border border-cyan-500/50 bg-cyan-500/10 px-3 py-2 font-mono text-sm text-cyan-600 dark:text-cyan-400 hover:bg-cyan-500/20 disabled:opacity-50"
>
{loading ? "..." : "Modifier le mot de passe"}
</button>
</form>
</section>
<p className="mt-6 font-mono text-xs text-zinc-500 dark:text-zinc-400">
<Link href="/dashboard" className="text-cyan-600 dark:text-cyan-400 hover:underline">
Retour au dashboard
</Link>
</p>
</div>
);
}

View File

@@ -0,0 +1,129 @@
"use client";
import { useState } from "react";
import { addShare, removeShare } from "@/actions/share";
interface User {
id: string;
email: string;
name: string | null;
}
interface SharedUser {
id: string;
user: { id: string; email: string; name: string | null };
}
interface ShareModalProps {
isOpen: boolean;
onClose: () => void;
evaluationId: string;
evaluatorId?: string | null;
users: User[];
sharedWith: SharedUser[];
onUpdate: () => void;
}
export function ShareModal({
isOpen,
onClose,
evaluationId,
evaluatorId,
users,
sharedWith,
onUpdate,
}: ShareModalProps) {
const [shareUserId, setShareUserId] = useState("");
const [loading, setLoading] = useState(false);
if (!isOpen) return null;
const availableUsers = users.filter(
(u) => u.id !== evaluatorId && !sharedWith.some((s) => s.user.id === u.id)
);
async function handleAdd() {
if (!shareUserId) return;
setLoading(true);
try {
const result = await addShare(evaluationId, shareUserId);
if (result.success) {
setShareUserId("");
onUpdate();
} else {
alert(result.error);
}
} finally {
setLoading(false);
}
}
async function handleRemove(userId: string) {
const result = await removeShare(evaluationId, userId);
if (result.success) onUpdate();
}
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-[calc(100%-2rem)] 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="Partager"
>
<h3 className="mb-4 font-mono text-sm font-medium text-zinc-800 dark:text-zinc-200">
Partager
</h3>
<p className="mb-3 font-mono text-xs text-zinc-600 dark:text-zinc-400">
Ajouter un utilisateur pour lui donner accès.
</p>
<div className="mb-3 flex flex-col gap-2 sm:flex-row sm:items-center">
<select
value={shareUserId}
onChange={(e) => setShareUserId(e.target.value)}
className="w-full min-w-0 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 sm:flex-1"
>
<option value=""> choisir </option>
{availableUsers.map((u) => (
<option key={u.id} value={u.id}>
{u.name || u.email} ({u.email})
</option>
))}
</select>
<button
type="button"
disabled={!shareUserId || loading}
onClick={handleAdd}
className="w-full shrink-0 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 disabled:opacity-50 sm:w-auto"
>
{loading ? "..." : "ajouter"}
</button>
</div>
{sharedWith.length > 0 && (
<ul className="mb-4 space-y-1 font-mono text-xs text-zinc-600 dark:text-zinc-400">
{sharedWith.map((s) => (
<li key={s.id} className="flex items-center justify-between gap-2">
<span>{s.user.name || s.user.email}</span>
<button
type="button"
onClick={() => handleRemove(s.user.id)}
className="text-red-500 hover:text-red-400"
title="Retirer"
>
×
</button>
</li>
))}
</ul>
)}
<button
type="button"
onClick={onClose}
className="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>
</>
);
}

View File

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

View File

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

View File

@@ -9,7 +9,7 @@ export function ThemeToggle() {
<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"
className="flex items-center justify-center w-8 h-8 rounded border border-zinc-200 dark:border-zinc-600 bg-zinc-50 dark:bg-zinc-800 text-lg text-zinc-600 dark:text-zinc-300 hover:border-zinc-300 dark:hover:border-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-700 transition-colors"
title={theme === "dark" ? "Passer au thème clair" : "Passer au thème sombre"}
>
{theme === "dark" ? "☀" : "☽"}

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

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

View File

@@ -0,0 +1,19 @@
import { prisma } from "@/lib/db";
export async function canAccessEvaluation(
evaluationId: string,
userId: string,
isAdmin: boolean,
readOnly = false
) {
if (isAdmin) return true;
const eval_ = await prisma.evaluation.findUnique({
where: { id: evaluationId },
select: { evaluatorId: true, isPublic: true, sharedWith: { select: { userId: true } } },
});
if (!eval_) return false;
if (eval_.evaluatorId === userId) return true;
if (eval_.sharedWith.some((s) => s.userId === userId)) return true;
if (readOnly && eval_.isPublic) return true;
return false;
}

View File

@@ -5,6 +5,28 @@ export interface EvaluationWithScores extends Evaluation {
dimensionScores: (DimensionScore & { dimension: TemplateDimension })[];
}
/** Parse suggestedQuestions JSON array */
export 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 [];
}
}
/** Parse rubric "1:X;2:Y;..." into labels */
export 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;
}
/** 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);
@@ -81,3 +103,66 @@ export function evaluationToCsvRows(evalData: EvaluationWithScores): string[][]
}
return rows;
}
const CONFIDENCE_LABELS: Record<string, string> = {
low: "Faible",
med: "Moyenne",
high: "Haute",
};
/** Convert evaluation template (dimensions + questions + rubric) to Markdown for Confluence paste */
export function evaluationToConfluenceMarkup(evalData: EvaluationWithScores): string {
const lines: string[] = [];
lines.push(`# Guide d'entretien — ${evalData.template?.name ?? "Évaluation"}`);
lines.push("");
lines.push(`**Candidat:** ${evalData.candidateName} | **Rôle:** ${evalData.candidateRole}`);
if (evalData.candidateTeam) lines.push(`**Équipe:** ${evalData.candidateTeam}`);
lines.push(`**Évaluateur:** ${evalData.evaluatorName} | **Date:** ${evalData.evaluationDate.toISOString().split("T")[0]}`);
lines.push("");
lines.push("## Système de notation");
lines.push("");
lines.push("Chaque dimension est notée de **1** (faible) à **5** (expert). La grille ci-dessous détaille les critères par niveau.");
lines.push("");
for (const ds of evalData.dimensionScores) {
const dim = ds.dimension;
const title = (dim as { title?: string; name?: string }).title ?? (dim as { name?: string }).name ?? "";
lines.push(`## ${title}`);
lines.push("");
const questions = parseQuestions((dim as { suggestedQuestions?: string | null }).suggestedQuestions);
if (questions.length > 0) {
lines.push("### Questions suggérées");
questions.forEach((q, i) => {
lines.push(`${i + 1}. ${q}`);
});
lines.push("");
}
const rubricLabels = parseRubric((dim as { rubric?: string }).rubric ?? "");
if (rubricLabels.length > 0) {
lines.push("### Grille");
rubricLabels.forEach((label, i) => {
lines.push(`- ${i + 1}: ${label}`);
});
lines.push("");
}
lines.push("### Notes évaluateur");
if (ds.score != null) {
lines.push(`- **Score:** ${ds.score}/5`);
if (ds.confidence)
lines.push(`- **Confiance:** ${CONFIDENCE_LABELS[ds.confidence] ?? ds.confidence}`);
} else {
lines.push(`- **Score:** _à compléter_`);
lines.push(`- **Confiance:** _à compléter_`);
}
lines.push(`- **Notes candidat:** ${ds.candidateNotes ?? "_à compléter_"}`);
lines.push(`- **Justification:** ${ds.justification ?? "_à compléter_"}`);
lines.push(`- **Exemples:** ${ds.examplesObserved ?? "_à compléter_"}`);
lines.push("");
}
return lines.join("\n");
}

98
src/lib/server-data.ts Normal file
View File

@@ -0,0 +1,98 @@
import { cache } from "react";
import { auth } from "@/auth";
import { prisma } from "@/lib/db";
import { canAccessEvaluation } from "@/lib/evaluation-access";
export async function getEvaluations(options?: { status?: string; templateId?: string }) {
const session = await auth();
if (!session?.user) return null;
const isAdmin = session.user.role === "admin";
const userId = session.user.id;
const evaluations = await prisma.evaluation.findMany({
where: {
...(options?.status && { status: options.status }),
...(options?.templateId && { templateId: options.templateId }),
...(!isAdmin && {
OR: [
{ evaluatorId: userId },
{ sharedWith: { some: { userId } } },
{ isPublic: true },
],
}),
},
include: {
template: { include: { dimensions: { orderBy: { orderIndex: "asc" } } } },
dimensionScores: { include: { dimension: true } },
},
orderBy: { evaluationDate: "desc" },
});
return evaluations.map((e) => ({
...e,
evaluationDate: e.evaluationDate.toISOString(),
}));
}
export async function getEvaluation(id: string) {
const session = await auth();
if (!session?.user) return null;
const evaluation = await prisma.evaluation.findUnique({
where: { id },
include: {
template: {
include: {
dimensions: { orderBy: { orderIndex: "asc" } },
},
},
dimensionScores: { include: { dimension: true } },
sharedWith: { include: { user: { select: { id: true, email: true, name: true } } } },
},
});
if (!evaluation) return null;
const hasAccess = await canAccessEvaluation(
id,
session.user.id,
session.user.role === "admin",
true
);
if (!hasAccess) return null;
return {
...evaluation,
evaluationDate: evaluation.evaluationDate.toISOString(),
};
}
export const getTemplates = cache(async () => {
const templates = await prisma.template.findMany({
include: {
dimensions: { orderBy: { orderIndex: "asc" } },
},
orderBy: { id: "desc" },
});
return templates;
});
export async function getUsers() {
const session = await auth();
if (!session?.user) return null;
return prisma.user.findMany({
orderBy: { email: "asc" },
select: { id: true, email: true, name: true },
});
}
export async function getAdminUsers() {
const session = await auth();
if (session?.user?.role !== "admin") return null;
const users = await prisma.user.findMany({
orderBy: { createdAt: "desc" },
select: { id: true, email: true, name: true, role: true, createdAt: true },
});
return users.map((u) => ({ ...u, createdAt: u.createdAt.toISOString() }));
}

29
src/middleware.ts Normal file
View File

@@ -0,0 +1,29 @@
import { auth } from "@/auth";
import { NextResponse } from "next/server";
export default auth((req) => {
const isLoggedIn = !!req.auth;
const isPublicRoute = req.nextUrl.pathname === "/";
const isAuthRoute =
req.nextUrl.pathname.startsWith("/auth/login") ||
req.nextUrl.pathname.startsWith("/auth/signup");
const isApiAuth = req.nextUrl.pathname.startsWith("/api/auth");
const isAdminRoute = req.nextUrl.pathname.startsWith("/admin");
if (isApiAuth) return NextResponse.next();
if (isPublicRoute) return NextResponse.next();
if (isAuthRoute && isLoggedIn) {
return NextResponse.redirect(new URL("/dashboard", req.nextUrl));
}
if (!isLoggedIn && !isAuthRoute) {
return NextResponse.redirect(new URL("/auth/login", req.nextUrl));
}
if (isAdminRoute && req.auth?.user?.role !== "admin") {
return NextResponse.redirect(new URL("/", req.nextUrl));
}
return NextResponse.next();
});
export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico)$).*)"],
};

23
src/types/next-auth.d.ts vendored Normal file
View File

@@ -0,0 +1,23 @@
import "next-auth";
declare module "next-auth" {
interface User {
id?: string;
role?: string;
}
interface Session {
user: {
id: string;
email?: string | null;
name?: string | null;
role: string;
};
}
}
declare module "next-auth/jwt" {
interface JWT {
id?: string;
role?: string;
}
}