Compare commits
35 Commits
34b2a8c5cc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e9b64694d | |||
| d4bfcb93c7 | |||
| 7662922a8b | |||
| 32e1f07418 | |||
| 88da5742ec | |||
| 17f5dfbf94 | |||
| e4a4e5a869 | |||
| 2d8d59322d | |||
| ebd8573299 | |||
| 27866091bf | |||
| 99e1a06137 | |||
| d9073c29bc | |||
| cfde81b8de | |||
| 437b5db1da | |||
| c1751a1ab6 | |||
| 895df3f7d9 | |||
| 92c819d339 | |||
| 87326b459e | |||
|
|
9ff745489f | ||
|
|
dee59991fc | ||
|
|
160e90fbde | ||
|
|
8073321b0f | ||
|
|
aab8a192d4 | ||
|
|
2ef9b4d6f9 | ||
|
|
dc8581f545 | ||
|
|
521975db31 | ||
|
|
04d5a9b9c2 | ||
|
|
65fee6baf7 | ||
|
|
e30cfedea8 | ||
|
|
328200f8b4 | ||
|
|
b1fb6762fe | ||
|
|
59f82e4072 | ||
|
|
9d8d1b257d | ||
|
|
f5cbc578b7 | ||
|
|
9a734dc1ed |
@@ -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
69
CLAUDE.md
Normal 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.
|
||||
@@ -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
267
README.md
@@ -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 2–3 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 1–5, 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 1–5, 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: 1–5, justification, examples, confidence
|
||||
- [x] Probing questions when score ≤ 2
|
||||
- [x] Radar chart + findings/recommendations
|
||||
- [x] Export PDF and CSV
|
||||
- [x] Admin: view templates
|
||||
- [x] Warning when all scores = 5 without comments
|
||||
- [x] Edit after submission (audit log)
|
||||
- [x] Mobile responsive (Tailwind)
|
||||
|
||||
## Manual Test Plan
|
||||
|
||||
1. **Dashboard**: Open `/`, verify evaluations table or empty state.
|
||||
2. **New evaluation**: Click "New Evaluation", fill form, select template, submit.
|
||||
3. **Interview guide**: On evaluation page, score dimensions, add notes, click "Get AI follow-up suggestions".
|
||||
4. **Low score**: Set a dimension to 1 or 2, verify probing questions appear.
|
||||
5. **All 5s**: Set all scores to 5 with no justification, submit — verify warning.
|
||||
6. **Aggregate**: Click "Auto-generate findings", verify radar chart and text.
|
||||
7. **Export**: Click Export, download CSV and PDF.
|
||||
8. **Admin**: Open `/admin`, verify templates listed.
|
||||
|
||||
## File Structure
|
||||
## 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 1–5, 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)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Dev avec hot reload (source montée)
|
||||
services:
|
||||
app:
|
||||
iag-dev-evaluator:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.dev
|
||||
|
||||
@@ -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
17
docker-start.sh
Normal 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
|
||||
@@ -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
92
pnpm-lock.yaml
generated
@@ -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:
|
||||
|
||||
100
prisma/migrations/20250220000000_init/migration.sql
Normal file
100
prisma/migrations/20250220000000_init/migration.sql
Normal 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");
|
||||
26
prisma/migrations/20260220120133_add_is_public/migration.sql
Normal file
26
prisma/migrations/20260220120133_add_is_public/migration.sql
Normal 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;
|
||||
@@ -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");
|
||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal 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"
|
||||
@@ -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])
|
||||
}
|
||||
|
||||
193
prisma/seed.ts
193
prisma/seed.ts
@@ -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
28
public/icon.svg
Normal 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
BIN
public/iconfull.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.1 MiB |
17
public/manifest.json
Normal file
17
public/manifest.json
Normal 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
42
src/actions/admin.ts
Normal 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
216
src/actions/evaluations.ts
Normal 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
65
src/actions/password.ts
Normal 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
56
src/actions/share.ts
Normal 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" };
|
||||
}
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
3
src/app/api/auth/[...nextauth]/route.ts
Normal file
3
src/app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import { handlers } from "@/auth";
|
||||
|
||||
export const { GET, POST } = handlers;
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
39
src/app/api/auth/signup/route.ts
Normal file
39
src/app/api/auth/signup/route.ts
Normal 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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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: { include: { dimensions: { orderBy: { orderIndex: "asc" } } } },
|
||||
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: { include: { dimensions: { orderBy: { orderIndex: "asc" } } } },
|
||||
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: { include: { dimensions: { orderBy: { orderIndex: "asc" } } } },
|
||||
dimensionScores: { include: { dimension: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(updated);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return NextResponse.json({ error: "Failed to create evaluation" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
39
src/app/api/export/confluence/route.ts
Normal file
39
src/app/api/export/confluence/route.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
85
src/app/auth/login/page.tsx
Normal file
85
src/app/auth/login/page.tsx
Normal 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'inscrire
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
109
src/app/auth/signup/page.tsx
Normal file
109
src/app/auth/signup/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
src/app/dashboard/page.tsx
Normal file
10
src/app/dashboard/page.tsx
Normal 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} />;
|
||||
}
|
||||
@@ -1,412 +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 [collapseAllTrigger, setCollapseAllTrigger] = useState(0);
|
||||
|
||||
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 = 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);
|
||||
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>
|
||||
<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 ? { ...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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
210
src/app/page.tsx
210
src/app/page.tsx
@@ -1,167 +1,69 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
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;
|
||||
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);
|
||||
}
|
||||
|
||||
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'entretien, rubriques 1→5, 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'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>
|
||||
|
||||
{loading ? (
|
||||
<div className="py-12 text-center font-mono text-xs text-zinc-600 dark:text-zinc-500">loading...</div>
|
||||
) : evaluations.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>
|
||||
) : (
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{evaluations.map((e) => {
|
||||
const radarData = buildRadarData(e);
|
||||
return (
|
||||
<Link
|
||||
key={e.id}
|
||||
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}`}
|
||||
|
||||
<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'entretien</span>
|
||||
<p className="mt-1 font-mono text-xs text-zinc-600 dark:text-zinc-400">
|
||||
Questions suggérées, relances IA, notation 1→5 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>
|
||||
<span
|
||||
className={`shrink-0 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 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={(ev) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
setDeleteTarget(e);
|
||||
}}
|
||||
className="ml-auto font-mono text-xs text-red-500 hover:text-red-400"
|
||||
title="Supprimer"
|
||||
>
|
||||
supprimer
|
||||
</button>
|
||||
</div>
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmModal
|
||||
isOpen={!!deleteTarget}
|
||||
title="Supprimer l'évaluation"
|
||||
message={
|
||||
deleteTarget
|
||||
? `Supprimer l'évaluation de ${deleteTarget.candidateName} ? Cette action est irréversible.`
|
||||
: ""
|
||||
}
|
||||
confirmLabel="Supprimer"
|
||||
cancelLabel="Annuler"
|
||||
variant="danger"
|
||||
onConfirm={async () => {
|
||||
if (!deleteTarget) return;
|
||||
const res = await fetch(`/api/evaluations/${deleteTarget.id}`, { method: "DELETE" });
|
||||
if (res.ok) setEvaluations((prev) => prev.filter((x) => x.id !== deleteTarget.id));
|
||||
else alert("Erreur lors de la suppression");
|
||||
}}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
16
src/app/settings/page.tsx
Normal file
16
src/app/settings/page.tsx
Normal 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
283
src/app/templates/page.tsx
Normal 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
59
src/auth.ts
Normal 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 },
|
||||
});
|
||||
174
src/components/AdminClient.tsx
Normal file
174
src/components/AdminClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
361
src/components/DashboardClient.tsx
Normal file
361
src/components/DashboardClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -55,44 +57,40 @@ interface DimensionCardProps {
|
||||
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, 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) {
|
||||
setExpanded(false);
|
||||
queueMicrotask(() => setExpanded(false));
|
||||
if (evaluationId) setStoredExpanded(evaluationId, dimension.id, false);
|
||||
}
|
||||
}, [collapseAllTrigger, evaluationId, dimension.id]);
|
||||
@@ -191,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>
|
||||
@@ -206,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>
|
||||
@@ -216,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>
|
||||
@@ -225,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>
|
||||
|
||||
421
src/components/EvaluationEditor.tsx
Normal file
421
src/components/EvaluationEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
75
src/components/NewEvaluationForm.tsx
Normal file
75
src/components/NewEvaluationForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Radar,
|
||||
RadarChart as RechartsRadar,
|
||||
@@ -43,10 +44,21 @@ const DARK = {
|
||||
|
||||
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={compact ? "h-28 w-full" : "h-72 w-full"}>
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
|
||||
7
src/components/SessionProvider.tsx
Normal file
7
src/components/SessionProvider.tsx
Normal 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>;
|
||||
}
|
||||
185
src/components/SettingsPasswordForm.tsx
Normal file
185
src/components/SettingsPasswordForm.tsx
Normal 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'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>
|
||||
);
|
||||
}
|
||||
129
src/components/ShareModal.tsx
Normal file
129
src/components/ShareModal.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
18
src/components/SignOutButton.tsx
Normal file
18
src/components/SignOutButton.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
52
src/components/TemplateCompareSelects.tsx
Normal file
52
src/components/TemplateCompareSelects.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
15
src/lib/action-helpers.ts
Normal 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;
|
||||
}
|
||||
19
src/lib/evaluation-access.ts
Normal file
19
src/lib/evaluation-access.ts
Normal 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;
|
||||
}
|
||||
@@ -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
98
src/lib/server-data.ts
Normal 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
29
src/middleware.ts
Normal 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
23
src/types/next-auth.d.ts
vendored
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user