Compare commits

..

20 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-20 16:25:27 +01:00
33 changed files with 1116 additions and 289 deletions

View File

@@ -20,4 +20,4 @@ jobs:
DB_VOLUME_PATH: ${{ variables.DB_VOLUME_PATH }} DB_VOLUME_PATH: ${{ variables.DB_VOLUME_PATH }}
run: | run: |
if [ -n "${DB_VOLUME_PATH}" ]; then mkdir -p "$DB_VOLUME_PATH"; fi if [ -n "${DB_VOLUME_PATH}" ]; then mkdir -p "$DB_VOLUME_PATH"; fi
docker compose up -d --build BUILDKIT_PROGRESS=plain docker compose up -d --build

267
README.md
View File

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

View File

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

View File

@@ -1,5 +1,5 @@
services: services:
app: iag-dev-evaluator:
build: . build: .
ports: ports:
- "3044:3000" - "3044:3000"

View File

@@ -12,4 +12,6 @@ if ! npx prisma migrate deploy 2>/dev/null; then
npx prisma migrate deploy npx prisma migrate deploy
fi fi
npx prisma db seed
exec node server.js exec node server.js

View File

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

View File

@@ -66,6 +66,9 @@ model Evaluation {
isPublic Boolean @default(false) // visible par tous (ex. démo) isPublic Boolean @default(false) // visible par tous (ex. démo)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@index([evaluatorId])
@@index([templateId])
} }
model EvaluationShare { model EvaluationShare {
@@ -77,6 +80,7 @@ model EvaluationShare {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@unique([evaluationId, userId]) @@unique([evaluationId, userId])
@@index([userId])
} }
model DimensionScore { model DimensionScore {
@@ -106,4 +110,6 @@ model AuditLog {
newValue String? newValue String?
userId String? userId String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@index([evaluationId])
} }

View File

@@ -78,6 +78,13 @@ const SUGGESTED_QUESTIONS: Record<string, string[]> = {
"Comment optimisez-vous (choix de modèles, taille du contexte, batch) ?", "Comment optimisez-vous (choix de modèles, taille du contexte, batch) ?",
"Comment arbitrez-vous coût vs qualité dans vos usages ?", "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> = { const RUBRICS: Record<string, string> = {
@@ -107,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", "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: 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é", "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) // Réponses réalistes par dimension et score (justification + exemples observés)
@@ -321,7 +348,7 @@ function getDemoResponse(
const TEMPLATES_DATA = [ const TEMPLATES_DATA = [
{ {
id: "full-15", id: "full-15",
name: "Full - 13 dimensions", name: "Full - 15 dimensions",
dimensions: [ dimensions: [
{ {
id: "tools", id: "tools",
@@ -384,6 +411,93 @@ const TEMPLATES_DATA = [
title: "[Optionnel] Impact sur la delivery", title: "[Optionnel] Impact sur la delivery",
rubric: RUBRICS.impact, 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,
},
], ],
}, },
]; ];
@@ -431,7 +545,7 @@ async function main() {
// Upsert répondants (candidates) par nom : create si absent, update si existant. Ne vide pas les évaluations. // Upsert répondants (candidates) par nom : create si absent, update si existant. Ne vide pas les évaluations.
const template = await prisma.template.findUnique({ const template = await prisma.template.findUnique({
where: { id: "full-15" }, where: { id: "full-15-v2" },
}); });
if (!template) throw new Error("Template not found"); if (!template) throw new Error("Template not found");

28
public/icon.svg Normal file
View File

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

After

Width:  |  Height:  |  Size: 1.4 KiB

BIN
public/iconfull.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

17
public/manifest.json Normal file
View File

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

View File

@@ -1,14 +1,13 @@
"use server"; "use server";
import { auth } from "@/auth";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { requireAuth, type ActionResult } from "@/lib/action-helpers";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
export type ActionResult<T = void> = { success: true; data?: T } | { success: false; error: string };
export async function setUserRole(userId: string, role: "admin" | "evaluator"): Promise<ActionResult> { export async function setUserRole(userId: string, role: "admin" | "evaluator"): Promise<ActionResult> {
const session = await auth(); const session = await requireAuth();
if (session?.user?.role !== "admin") return { success: false, error: "Forbidden" }; if (!session || session.user.role !== "admin") return { success: false, error: "Accès refusé" };
if (!role || !["admin", "evaluator"].includes(role)) { if (!role || !["admin", "evaluator"].includes(role)) {
return { success: false, error: "Rôle invalide (admin | evaluator)" }; return { success: false, error: "Rôle invalide (admin | evaluator)" };
@@ -25,8 +24,8 @@ export async function setUserRole(userId: string, role: "admin" | "evaluator"):
} }
export async function deleteUser(userId: string): Promise<ActionResult> { export async function deleteUser(userId: string): Promise<ActionResult> {
const session = await auth(); const session = await requireAuth();
if (session?.user?.role !== "admin") return { success: false, error: "Forbidden" }; if (!session || session.user.role !== "admin") return { success: false, error: "Accès refusé" };
if (userId === session.user.id) { if (userId === session.user.id) {
return { success: false, error: "Impossible de supprimer votre propre compte" }; return { success: false, error: "Impossible de supprimer votre propre compte" };

View File

@@ -1,16 +1,14 @@
"use server"; "use server";
import { auth } from "@/auth";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { canAccessEvaluation } from "@/lib/evaluation-access";
import { getEvaluation } from "@/lib/server-data"; import { getEvaluation } from "@/lib/server-data";
import { requireAuth, requireEvaluationAccess, type ActionResult } from "@/lib/action-helpers";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
export type ActionResult<T = void> = { success: true; data?: T } | { success: false; error: string };
export async function fetchEvaluation(id: string): Promise<ActionResult<Awaited<ReturnType<typeof getEvaluation>>>> { export async function fetchEvaluation(id: string): Promise<ActionResult<Awaited<ReturnType<typeof getEvaluation>>>> {
const session = await auth(); const session = await requireAuth();
if (!session?.user) return { success: false, error: "Non authentifié" }; if (!session) return { success: false, error: "Non authentifié" };
const evaluation = await getEvaluation(id); const evaluation = await getEvaluation(id);
if (!evaluation) return { success: false, error: "Évaluation introuvable" }; if (!evaluation) return { success: false, error: "Évaluation introuvable" };
@@ -19,10 +17,10 @@ export async function fetchEvaluation(id: string): Promise<ActionResult<Awaited<
} }
export async function deleteEvaluation(id: string): Promise<ActionResult> { export async function deleteEvaluation(id: string): Promise<ActionResult> {
const session = await auth(); const session = await requireAuth();
if (!session?.user) return { success: false, error: "Non authentifié" }; if (!session) return { success: false, error: "Non authentifié" };
const hasAccess = await canAccessEvaluation(id, session.user.id, session.user.role === "admin"); const hasAccess = await requireEvaluationAccess(id, session.user.id, session.user.role === "admin");
if (!hasAccess) return { success: false, error: "Accès refusé" }; if (!hasAccess) return { success: false, error: "Accès refusé" };
try { try {
@@ -42,8 +40,8 @@ export async function createEvaluation(data: {
evaluationDate: string; evaluationDate: string;
templateId: string; templateId: string;
}): Promise<ActionResult<{ id: string }>> { }): Promise<ActionResult<{ id: string }>> {
const session = await auth(); const session = await requireAuth();
if (!session?.user) return { success: false, error: "Non authentifié" }; if (!session) return { success: false, error: "Non authentifié" };
const { candidateName, candidateRole, candidateTeam, evaluationDate, templateId } = data; const { candidateName, candidateRole, candidateTeam, evaluationDate, templateId } = data;
if (!candidateName || !candidateRole || !evaluationDate || !templateId) { if (!candidateName || !candidateRole || !evaluationDate || !templateId) {
@@ -72,11 +70,12 @@ export async function createEvaluation(data: {
}, },
}); });
for (const dim of template.dimensions) { await prisma.dimensionScore.createMany({
await prisma.dimensionScore.create({ data: template.dimensions.map((dim) => ({
data: { evaluationId: evaluation.id, dimensionId: dim.id }, evaluationId: evaluation.id,
dimensionId: dim.id,
})),
}); });
}
revalidatePath("/dashboard"); revalidatePath("/dashboard");
return { success: true, data: { id: evaluation.id } }; return { success: true, data: { id: evaluation.id } };
@@ -107,11 +106,35 @@ export interface UpdateEvaluationInput {
}[]; }[];
} }
export async function updateEvaluation(id: string, data: UpdateEvaluationInput): Promise<ActionResult> { export async function updateDimensionScore(
const session = await auth(); evaluationId: string,
if (!session?.user) return { success: false, error: "Non authentifié" }; 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 canAccessEvaluation(id, session.user.id, session.user.role === "admin"); 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é" }; if (!hasAccess) return { success: false, error: "Accès refusé" };
const existing = await prisma.evaluation.findUnique({ where: { id } }); const existing = await prisma.evaluation.findUnique({ where: { id } });
@@ -153,9 +176,11 @@ export async function updateEvaluation(id: string, data: UpdateEvaluationInput):
} }
if (dimensionScores && Array.isArray(dimensionScores)) { if (dimensionScores && Array.isArray(dimensionScores)) {
for (const ds of dimensionScores) { const validScores = dimensionScores.filter((ds) => ds.dimensionId);
if (ds.dimensionId) { if (validScores.length > 0) {
await prisma.dimensionScore.upsert({ await prisma.$transaction(
validScores.map((ds) =>
prisma.dimensionScore.upsert({
where: { where: {
evaluationId_dimensionId: { evaluationId: id, dimensionId: ds.dimensionId }, evaluationId_dimensionId: { evaluationId: id, dimensionId: ds.dimensionId },
}, },
@@ -175,8 +200,9 @@ export async function updateEvaluation(id: string, data: UpdateEvaluationInput):
confidence: ds.confidence, confidence: ds.confidence,
candidateNotes: ds.candidateNotes, candidateNotes: ds.candidateNotes,
}, },
}); })
} )
);
} }
} }

View File

@@ -1,21 +1,15 @@
"use server"; "use server";
import { auth } from "@/auth";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { canAccessEvaluation } from "@/lib/evaluation-access"; import { requireAuth, requireEvaluationAccess, type ActionResult } from "@/lib/action-helpers";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
export type ActionResult<T = void> = { success: true; data?: T } | { success: false; error: string };
export async function addShare(evaluationId: string, userId: string): Promise<ActionResult> { export async function addShare(evaluationId: string, userId: string): Promise<ActionResult> {
const session = await auth(); const session = await requireAuth();
if (!session?.user) return { success: false, error: "Non authentifié" }; if (!session) return { success: false, error: "Non authentifié" };
const hasAccess = await canAccessEvaluation( const hasAccess = await requireEvaluationAccess(evaluationId, session.user.id, session.user.role === "admin");
evaluationId,
session.user.id,
session.user.role === "admin"
);
if (!hasAccess) return { success: false, error: "Accès refusé" }; if (!hasAccess) return { success: false, error: "Accès refusé" };
if (userId === session.user.id) return { success: false, error: "Vous avez déjà accès" }; if (userId === session.user.id) return { success: false, error: "Vous avez déjà accès" };
@@ -43,14 +37,10 @@ export async function addShare(evaluationId: string, userId: string): Promise<Ac
} }
export async function removeShare(evaluationId: string, userId: string): Promise<ActionResult> { export async function removeShare(evaluationId: string, userId: string): Promise<ActionResult> {
const session = await auth(); const session = await requireAuth();
if (!session?.user) return { success: false, error: "Non authentifié" }; if (!session) return { success: false, error: "Non authentifié" };
const hasAccess = await canAccessEvaluation( const hasAccess = await requireEvaluationAccess(evaluationId, session.user.id, session.user.role === "admin");
evaluationId,
session.user.id,
session.user.role === "admin"
);
if (!hasAccess) return { success: false, error: "Accès refusé" }; if (!hasAccess) return { success: false, error: "Accès refusé" };
try { try {

View File

@@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/db";
import { evaluationToConfluenceMarkup } from "@/lib/export-utils";
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url);
const id = searchParams.get("id");
if (!id) {
return NextResponse.json({ error: "Evaluation id required" }, { status: 400 });
}
const evaluation = await prisma.evaluation.findUnique({
where: { id },
include: {
template: true,
dimensionScores: { include: { dimension: true } },
},
});
if (!evaluation) {
return NextResponse.json({ error: "Evaluation not found" }, { status: 404 });
}
const markup = evaluationToConfluenceMarkup(
evaluation as Parameters<typeof evaluationToConfluenceMarkup>[0]
);
return new NextResponse(markup, {
headers: {
"Content-Type": "text/plain; charset=utf-8",
"Content-Disposition": `attachment; filename="guide-entretien-${id}.md"`,
},
});
} catch (e) {
console.error(e);
return NextResponse.json({ error: "Export failed" }, { status: 500 });
}
}

View File

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

View File

@@ -1,5 +1,6 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google"; import { Geist, Geist_Mono } from "next/font/google";
import { auth } from "@/auth";
import { Header } from "@/components/Header"; import { Header } from "@/components/Header";
import { ThemeProvider } from "@/components/ThemeProvider"; import { ThemeProvider } from "@/components/ThemeProvider";
import { SessionProvider } from "@/components/SessionProvider"; import { SessionProvider } from "@/components/SessionProvider";
@@ -18,20 +19,27 @@ const geistMono = Geist_Mono({
export const metadata: Metadata = { export const metadata: Metadata = {
title: "Évaluateur Maturité IA Gen", title: "Évaluateur Maturité IA Gen",
description: "Outil d'évaluation de la maturité IA Gen par Peaksys", 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, children,
}: Readonly<{ }: Readonly<{
children: React.ReactNode; children: React.ReactNode;
}>) { }>) {
const session = await auth();
return ( return (
<html lang="fr" suppressHydrationWarning> <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`}> <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> <SessionProvider>
<ThemeProvider> <ThemeProvider>
<Header /> <Header session={session} />
<main className="mx-auto max-w-5xl px-4 py-6">{children}</main> <main className="mx-auto max-w-7xl px-4 py-6">{children}</main>
</ThemeProvider> </ThemeProvider>
</SessionProvider> </SessionProvider>
</body> </body>

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

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

View File

@@ -1,6 +1,10 @@
"use client"; "use client";
import { useState } from "react";
import { updateEvaluation } from "@/actions/evaluations";
interface CandidateFormProps { interface CandidateFormProps {
evaluationId?: string;
candidateName: string; candidateName: string;
candidateRole: string; candidateRole: string;
candidateTeam?: string; candidateTeam?: string;
@@ -18,7 +22,10 @@ const inputClass =
const labelClass = "mb-1 block text-xs font-medium text-zinc-500 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({ export function CandidateForm({
evaluationId,
candidateName, candidateName,
candidateRole, candidateRole,
candidateTeam = "", candidateTeam = "",
@@ -30,6 +37,25 @@ export function CandidateForm({
disabled, disabled,
templateDisabled, templateDisabled,
}: CandidateFormProps) { }: 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 ( return (
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
<div className="sm:col-span-2 lg:col-span-1"> <div className="sm:col-span-2 lg:col-span-1">
@@ -37,8 +63,10 @@ export function CandidateForm({
<input <input
type="text" type="text"
value={candidateName} value={candidateName}
onChange={(e) => onChange("candidateName", e.target.value)} onChange={(e) => markDirty("candidateName", e.target.value)}
className={inputClass} onBlur={(e) => saveOnBlur("candidateName", e.target.value)}
className={`${inputClass} transition-colors duration-200`}
style={fieldStyle("candidateName")}
disabled={disabled} disabled={disabled}
placeholder="Alice Chen" placeholder="Alice Chen"
/> />
@@ -48,8 +76,10 @@ export function CandidateForm({
<input <input
type="text" type="text"
value={candidateRole} value={candidateRole}
onChange={(e) => onChange("candidateRole", e.target.value)} onChange={(e) => markDirty("candidateRole", e.target.value)}
className={inputClass} onBlur={(e) => saveOnBlur("candidateRole", e.target.value)}
className={`${inputClass} transition-colors duration-200`}
style={fieldStyle("candidateRole")}
disabled={disabled} disabled={disabled}
placeholder="ML Engineer" placeholder="ML Engineer"
/> />
@@ -59,8 +89,10 @@ export function CandidateForm({
<input <input
type="text" type="text"
value={candidateTeam} value={candidateTeam}
onChange={(e) => onChange("candidateTeam", e.target.value)} onChange={(e) => markDirty("candidateTeam", e.target.value)}
className={inputClass} onBlur={(e) => saveOnBlur("candidateTeam", e.target.value)}
className={`${inputClass} transition-colors duration-200`}
style={fieldStyle("candidateTeam")}
disabled={disabled} disabled={disabled}
placeholder="Peaksys" placeholder="Peaksys"
/> />
@@ -71,8 +103,10 @@ export function CandidateForm({
<input <input
type="text" type="text"
value={evaluatorName} value={evaluatorName}
onChange={(e) => onChange("evaluatorName", e.target.value)} onChange={(e) => markDirty("evaluatorName", e.target.value)}
className={inputClass} onBlur={(e) => saveOnBlur("evaluatorName", e.target.value)}
className={`${inputClass} transition-colors duration-200`}
style={fieldStyle("evaluatorName")}
disabled={disabled} disabled={disabled}
placeholder="Jean D." placeholder="Jean D."
/> />
@@ -82,8 +116,10 @@ export function CandidateForm({
<input <input
type="date" type="date"
value={evaluationDate} value={evaluationDate}
onChange={(e) => onChange("evaluationDate", e.target.value)} onChange={(e) => markDirty("evaluationDate", e.target.value)}
className={inputClass} onBlur={(e) => saveOnBlur("evaluationDate", e.target.value)}
className={`${inputClass} transition-colors duration-200`}
style={fieldStyle("evaluationDate")}
disabled={disabled} disabled={disabled}
/> />
</div> </div>
@@ -91,8 +127,10 @@ export function CandidateForm({
<label className={labelClass}>Modèle</label> <label className={labelClass}>Modèle</label>
<select <select
value={templateId} value={templateId}
onChange={(e) => onChange("templateId", e.target.value)} onChange={(e) => markDirty("templateId", e.target.value)}
className={inputClass} onBlur={(e) => saveOnBlur("templateId", e.target.value)}
className={`${inputClass} transition-colors duration-200`}
style={fieldStyle("templateId")}
disabled={disabled || templateDisabled} disabled={disabled || templateDisabled}
> >
<option value=""></option> <option value=""></option>

View File

@@ -21,6 +21,7 @@ interface EvalRow {
evaluationDate: string; evaluationDate: string;
template?: { name: string; dimensions?: Dimension[] }; template?: { name: string; dimensions?: Dimension[] };
status: string; status: string;
isPublic?: boolean;
dimensionScores?: { dimensionId: string; score: number | null; dimension?: { title: string } }[]; dimensionScores?: { dimensionId: string; score: number | null; dimension?: { title: string } }[];
} }
@@ -86,14 +87,21 @@ function EvalCard({
{e.candidateTeam && ` · ${e.candidateTeam}`} {e.candidateTeam && ` · ${e.candidateTeam}`}
</p> </p>
</div> </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 <span
className={`shrink-0 font-mono text-xs px-1.5 py-0.5 rounded ${ 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" ? "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"} {e.status === "submitted" ? "ok" : "draft"}
</span> </span>
</div> </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"> <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>{e.evaluatorName}</span>
<span>{format(new Date(e.evaluationDate), "yyyy-MM-dd")}</span> <span>{format(new Date(e.evaluationDate), "yyyy-MM-dd")}</span>
@@ -297,7 +305,7 @@ export function DashboardClient({ evaluations }: DashboardClientProps) {
<h2 className="mb-3 font-mono text-sm font-medium text-zinc-600 dark:text-zinc-400"> <h2 className="mb-3 font-mono text-sm font-medium text-zinc-600 dark:text-zinc-400">
{team ?? "Sans équipe"} {team ?? "Sans équipe"}
</h2> </h2>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 [grid-template-columns:repeat(auto-fill,minmax(300px,1fr))]">
{evals.map((e) => ( {evals.map((e) => (
<EvalCard <EvalCard
key={e.id} key={e.id}
@@ -314,7 +322,7 @@ export function DashboardClient({ evaluations }: DashboardClientProps) {
))} ))}
</div> </div>
) : ( ) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3"> <div className="grid gap-4 [grid-template-columns:repeat(auto-fill,minmax(300px,1fr))]">
{list.map((e) => ( {list.map((e) => (
<EvalCard <EvalCard
key={e.id} key={e.id}

View File

@@ -1,6 +1,8 @@
"use client"; "use client";
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { updateDimensionScore } from "@/actions/evaluations";
import { parseQuestions, parseRubric } from "@/lib/export-utils";
const STORAGE_KEY_PREFIX = "eval-dim-expanded"; const STORAGE_KEY_PREFIX = "eval-dim-expanded";
@@ -55,31 +57,27 @@ interface DimensionCardProps {
collapseAllTrigger?: number; 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 = 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"; "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) { export function DimensionCard({ dimension, score, index, evaluationId, onScoreChange, collapseAllTrigger }: DimensionCardProps) {
const [notes, setNotes] = useState(score?.candidateNotes ?? ""); 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 hasQuestions = parseQuestions(dimension.suggestedQuestions).length > 0;
const [expanded, setExpanded] = useState(hasQuestions); const [expanded, setExpanded] = useState(hasQuestions);
@@ -191,10 +189,13 @@ export function DimensionCard({ dimension, score, index, evaluationId, onScoreCh
value={notes} value={notes}
onChange={(e) => { onChange={(e) => {
setNotes(e.target.value); setNotes(e.target.value);
markDirty("notes");
onScoreChange(dimension.id, { candidateNotes: e.target.value }); onScoreChange(dimension.id, { candidateNotes: e.target.value });
}} }}
onBlur={() => saveOnBlur("notes", { candidateNotes: notes })}
rows={2} rows={2}
className={inputClass} className={`${inputClass} transition-colors duration-200`}
style={savedField === "notes" ? savedStyle : undefined}
placeholder="Réponses du candidat..." placeholder="Réponses du candidat..."
/> />
</div> </div>
@@ -206,8 +207,13 @@ export function DimensionCard({ dimension, score, index, evaluationId, onScoreCh
<input <input
type="text" type="text"
value={score?.justification ?? ""} value={score?.justification ?? ""}
onChange={(e) => onScoreChange(dimension.id, { justification: e.target.value || null })} onChange={(e) => {
className={inputClass} 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..." placeholder="Courte..."
/> />
</div> </div>
@@ -216,8 +222,13 @@ export function DimensionCard({ dimension, score, index, evaluationId, onScoreCh
<input <input
type="text" type="text"
value={score?.examplesObserved ?? ""} value={score?.examplesObserved ?? ""}
onChange={(e) => onScoreChange(dimension.id, { examplesObserved: e.target.value || null })} onChange={(e) => {
className={inputClass} 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..." placeholder="Concrets..."
/> />
</div> </div>
@@ -225,8 +236,13 @@ export function DimensionCard({ dimension, score, index, evaluationId, onScoreCh
<label className="text-xs text-zinc-500">Confiance</label> <label className="text-xs text-zinc-500">Confiance</label>
<select <select
value={score?.confidence ?? ""} value={score?.confidence ?? ""}
onChange={(e) => onScoreChange(dimension.id, { confidence: e.target.value || null })} onChange={(e) => {
className={inputClass} 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=""></option>
<option value="low">Faible</option> <option value="low">Faible</option>

View File

@@ -1,7 +1,6 @@
"use client"; "use client";
import { useState, useEffect, useCallback } from "react"; import { useState, useEffect, useCallback } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { updateEvaluation, deleteEvaluation, fetchEvaluation } from "@/actions/evaluations"; import { updateEvaluation, deleteEvaluation, fetchEvaluation } from "@/actions/evaluations";
import { CandidateForm } from "@/components/CandidateForm"; import { CandidateForm } from "@/components/CandidateForm";
@@ -60,6 +59,7 @@ export function EvaluationEditor({ id, initialEvaluation, templates, users }: Ev
const router = useRouter(); const router = useRouter();
const [evaluation, setEvaluation] = useState<Evaluation>(initialEvaluation); const [evaluation, setEvaluation] = useState<Evaluation>(initialEvaluation);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [exportOpen, setExportOpen] = useState(false); const [exportOpen, setExportOpen] = useState(false);
const [shareOpen, setShareOpen] = useState(false); const [shareOpen, setShareOpen] = useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
@@ -150,6 +150,8 @@ export function EvaluationEditor({ id, initialEvaluation, templates, users }: Ev
}); });
if (result.success) { if (result.success) {
if (!options?.skipRefresh) fetchEval(); if (!options?.skipRefresh) fetchEval();
setSaved(true);
setTimeout(() => setSaved(false), 2000);
} else { } else {
alert(result.error); alert(result.error);
} }
@@ -209,9 +211,20 @@ export function EvaluationEditor({ id, initialEvaluation, templates, users }: Ev
<button <button
onClick={() => handleSave()} onClick={() => handleSave()}
disabled={saving} 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" 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 ? "..." : "save"} {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>
<button <button
onClick={() => { onClick={() => {
@@ -254,6 +267,7 @@ export function EvaluationEditor({ id, initialEvaluation, templates, users }: Ev
Session Session
</h2> </h2>
<CandidateForm <CandidateForm
evaluationId={id}
candidateName={evaluation.candidateName} candidateName={evaluation.candidateName}
candidateRole={evaluation.candidateRole} candidateRole={evaluation.candidateRole}
candidateTeam={evaluation.candidateTeam ?? ""} candidateTeam={evaluation.candidateTeam ?? ""}

View File

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

View File

@@ -1,12 +1,9 @@
"use client";
import Link from "next/link"; import Link from "next/link";
import { signOut, useSession } from "next-auth/react"; import type { Session } from "next-auth";
import { ThemeToggle } from "./ThemeToggle"; import { ThemeToggle } from "./ThemeToggle";
import { SignOutButton } from "./SignOutButton";
export function Header() { export function Header({ session }: { session: Session | null }) {
const { data: session, status } = useSession();
return ( 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"> <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"> <div className="mx-auto flex h-12 max-w-6xl items-center justify-between px-4">
@@ -17,7 +14,7 @@ export function Header() {
iag-eval iag-eval
</Link> </Link>
<nav className="flex items-center gap-6 font-mono text-xs"> <nav className="flex items-center gap-6 font-mono text-xs">
{status === "authenticated" ? ( {session ? (
<> <>
<Link <Link
href="/dashboard" href="/dashboard"
@@ -31,7 +28,13 @@ export function Header() {
> >
/new /new
</Link> </Link>
{session?.user?.role === "admin" && ( <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 <Link
href="/admin" href="/admin"
className="text-zinc-500 hover:text-cyan-600 dark:text-zinc-400 dark:hover:text-cyan-400 transition-colors" className="text-zinc-500 hover:text-cyan-600 dark:text-zinc-400 dark:hover:text-cyan-400 transition-colors"
@@ -46,18 +49,9 @@ export function Header() {
/paramètres /paramètres
</Link> </Link>
<span className="text-zinc-400 dark:text-zinc-500"> <span className="text-zinc-400 dark:text-zinc-500">
{session?.user?.email} {session.user.email}
</span> </span>
<button <SignOutButton />
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>
</> </>
) : ( ) : (
<Link <Link

View File

@@ -1,5 +1,6 @@
"use client"; "use client";
import { useEffect, useState } from "react";
import { import {
Radar, Radar,
RadarChart as RechartsRadar, RadarChart as RechartsRadar,
@@ -43,10 +44,21 @@ const DARK = {
export function RadarChart({ data, compact }: RadarChartProps) { export function RadarChart({ data, compact }: RadarChartProps) {
const { theme } = useTheme(); 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; const c = theme === "dark" ? DARK : LIGHT;
if (data.length === 0) return null; 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 ( return (
<div className={compact ? "h-28 w-full" : "h-72 w-full"}> <div className={compact ? "h-28 w-full" : "h-72 w-full"}>
<ResponsiveContainer width="100%" height="100%"> <ResponsiveContainer width="100%" height="100%">

View File

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

View File

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

View File

@@ -9,7 +9,7 @@ export function ThemeToggle() {
<button <button
type="button" type="button"
onClick={() => setTheme(theme === "dark" ? "light" : "dark")} 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"} title={theme === "dark" ? "Passer au thème clair" : "Passer au thème sombre"}
> >
{theme === "dark" ? "☀" : "☽"} {theme === "dark" ? "☀" : "☽"}

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

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

View File

@@ -5,6 +5,28 @@ export interface EvaluationWithScores extends Evaluation {
dimensionScores: (DimensionScore & { dimension: TemplateDimension })[]; 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) */ /** Compute average score across dimensions (1-5 scale) */
export function computeAverageScore(scores: { score: number | null }[]): number { export function computeAverageScore(scores: { score: number | null }[]): number {
const valid = scores.filter((s) => s.score != null && s.score >= 1 && s.score <= 5); 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; 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");
}

View File

@@ -1,4 +1,4 @@
import { Prisma } from "@prisma/client"; import { cache } from "react";
import { auth } from "@/auth"; import { auth } from "@/auth";
import { prisma } from "@/lib/db"; import { prisma } from "@/lib/db";
import { canAccessEvaluation } from "@/lib/evaluation-access"; import { canAccessEvaluation } from "@/lib/evaluation-access";
@@ -62,60 +62,21 @@ export async function getEvaluation(id: string) {
); );
if (!hasAccess) return null; if (!hasAccess) return null;
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]));
return { return {
...evaluation, ...evaluation,
evaluationDate: evaluation.evaluationDate.toISOString(), evaluationDate: evaluation.evaluationDate.toISOString(),
template: evaluation.template
? {
...evaluation.template,
dimensions: evaluation.template.dimensions.map((d) => {
const raw = dimMap.get(d.id);
return {
...d,
suggestedQuestions: raw?.suggestedQuestions ?? d.suggestedQuestions,
};
}),
}
: null,
dimensionScores: evaluation.dimensionScores.map((ds) => ({
...ds,
dimension: ds.dimension
? {
...ds.dimension,
suggestedQuestions: dimMap.get(ds.dimension.id)?.suggestedQuestions ?? ds.dimension.suggestedQuestions,
}
: null,
})),
}; };
} }
export async function getTemplates() { export const getTemplates = cache(async () => {
const templates = await prisma.template.findMany({ const templates = await prisma.template.findMany({
include: { include: {
dimensions: { orderBy: { orderIndex: "asc" } }, dimensions: { orderBy: { orderIndex: "asc" } },
}, },
orderBy: { id: "desc" },
}); });
const dimsRaw = (await prisma.$queryRaw( return templates;
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]));
return templates.map((t) => ({
...t,
dimensions: t.dimensions.map((d) => ({
...d,
suggestedQuestions: dimMap.get(d.id)?.suggestedQuestions ?? d.suggestedQuestions,
})),
}));
}
export async function getUsers() { export async function getUsers() {
const session = await auth(); const session = await auth();

View File

@@ -25,5 +25,5 @@ export default auth((req) => {
}); });
export const config = { export const config = {
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"], matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico)$).*)"],
}; };

View File

@@ -10,7 +10,7 @@ declare module "next-auth" {
id: string; id: string;
email?: string | null; email?: string | null;
name?: string | null; name?: string | null;
role?: string; role: string;
}; };
} }
} }