Compare commits
20 Commits
160e90fbde
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e9b64694d | |||
| d4bfcb93c7 | |||
| 7662922a8b | |||
| 32e1f07418 | |||
| 88da5742ec | |||
| 17f5dfbf94 | |||
| e4a4e5a869 | |||
| 2d8d59322d | |||
| ebd8573299 | |||
| 27866091bf | |||
| 99e1a06137 | |||
| d9073c29bc | |||
| cfde81b8de | |||
| 437b5db1da | |||
| c1751a1ab6 | |||
| 895df3f7d9 | |||
| 92c819d339 | |||
| 87326b459e | |||
|
|
9ff745489f | ||
|
|
dee59991fc |
@@ -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
267
README.md
@@ -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 2–3 probing interview questions."*
|
```
|
||||||
4. Return `{ suggestions: string[] }`.
|
|
||||||
|
|
||||||
The client already calls this API when the user clicks "Get AI follow-up suggestions" in the dimension card.
|
> 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
|
## 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: 1–5, justification, examples, confidence
|
|
||||||
- [x] Probing questions when score ≤ 2
|
|
||||||
- [x] Radar chart + findings/recommendations
|
|
||||||
- [x] Export PDF and CSV
|
|
||||||
- [x] Admin: view templates
|
|
||||||
- [x] Warning when all scores = 5 without comments
|
|
||||||
- [x] Edit after submission (audit log)
|
|
||||||
- [x] Mobile responsive (Tailwind)
|
|
||||||
|
|
||||||
## Manual Test Plan
|
|
||||||
|
|
||||||
1. **Dashboard**: Open `/`, verify evaluations table or empty state.
|
|
||||||
2. **New evaluation**: Click "New Evaluation", fill form, select template, submit.
|
|
||||||
3. **Interview guide**: On evaluation page, score dimensions, add notes, click "Get AI follow-up suggestions".
|
|
||||||
4. **Low score**: Set a dimension to 1 or 2, verify probing questions appear.
|
|
||||||
5. **All 5s**: Set all scores to 5 with no justification, submit — verify warning.
|
|
||||||
6. **Aggregate**: Click "Auto-generate findings", verify radar chart and text.
|
|
||||||
7. **Export**: Click Export, download CSV and PDF.
|
|
||||||
8. **Admin**: Open `/admin`, verify templates listed.
|
|
||||||
|
|
||||||
## File Structure
|
|
||||||
|
|
||||||
```
|
```
|
||||||
src/
|
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 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)
|
# Dev avec hot reload (source montée)
|
||||||
services:
|
services:
|
||||||
app:
|
iag-dev-evaluator:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: Dockerfile.dev
|
dockerfile: Dockerfile.dev
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
services:
|
services:
|
||||||
app:
|
iag-dev-evaluator:
|
||||||
build: .
|
build: .
|
||||||
ports:
|
ports:
|
||||||
- "3044:3000"
|
- "3044:3000"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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");
|
||||||
@@ -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])
|
||||||
}
|
}
|
||||||
|
|||||||
118
prisma/seed.ts
118
prisma/seed.ts
@@ -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
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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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" };
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
});
|
})
|
||||||
}
|
)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 ?? ""}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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%">
|
||||||
|
|||||||
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
|
<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
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;
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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)$).*)"],
|
||||||
};
|
};
|
||||||
|
|||||||
2
src/types/next-auth.d.ts
vendored
2
src/types/next-auth.d.ts
vendored
@@ -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;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user