From aab8a192d47b24a739c3bb0bad5cd35ae3843660 Mon Sep 17 00:00:00 2001 From: Julien Froidefond Date: Fri, 20 Feb 2026 14:08:18 +0100 Subject: [PATCH] Refactor evaluation and admin pages to use server actions for data fetching, enhancing performance and simplifying state management. Update README to reflect API route changes and remove deprecated API endpoints for users and evaluations. --- README.md | 11 +- src/actions/admin.ts | 43 ++ src/actions/evaluations.ts | 190 +++++++ src/actions/password.ts | 45 ++ src/actions/share.ts | 66 +++ src/app/admin/page.tsx | 197 +------- src/app/api/admin/users/[id]/route.ts | 44 -- src/app/api/admin/users/route.ts | 21 - src/app/api/evaluations/[id]/route.ts | 241 --------- .../evaluations/[id]/share/[userId]/route.ts | 46 -- src/app/api/evaluations/[id]/share/route.ts | 111 ----- src/app/api/evaluations/route.ts | 110 ----- src/app/api/templates/data/route.ts | 29 -- src/app/api/templates/route.ts | 29 -- src/app/api/users/me/password/route.ts | 60 --- src/app/api/users/route.ts | 20 - src/app/dashboard/page.tsx | 171 +------ src/app/evaluations/[id]/page.tsx | 462 ++---------------- src/app/evaluations/new/page.tsx | 108 +--- src/app/settings/page.tsx | 133 +---- src/components/AdminClient.tsx | 174 +++++++ src/components/DashboardClient.tsx | 161 ++++++ src/components/EvaluationEditor.tsx | 407 +++++++++++++++ src/components/NewEvaluationForm.tsx | 75 +++ src/components/SettingsPasswordForm.tsx | 122 +++++ src/components/ShareModal.tsx | 18 +- src/lib/evaluation-access.ts | 19 + src/lib/server-data.ts | 137 ++++++ 28 files changed, 1511 insertions(+), 1739 deletions(-) create mode 100644 src/actions/admin.ts create mode 100644 src/actions/evaluations.ts create mode 100644 src/actions/password.ts create mode 100644 src/actions/share.ts delete mode 100644 src/app/api/admin/users/[id]/route.ts delete mode 100644 src/app/api/admin/users/route.ts delete mode 100644 src/app/api/evaluations/[id]/route.ts delete mode 100644 src/app/api/evaluations/[id]/share/[userId]/route.ts delete mode 100644 src/app/api/evaluations/[id]/share/route.ts delete mode 100644 src/app/api/evaluations/route.ts delete mode 100644 src/app/api/templates/data/route.ts delete mode 100644 src/app/api/templates/route.ts delete mode 100644 src/app/api/users/me/password/route.ts delete mode 100644 src/app/api/users/route.ts create mode 100644 src/components/AdminClient.tsx create mode 100644 src/components/DashboardClient.tsx create mode 100644 src/components/EvaluationEditor.tsx create mode 100644 src/components/NewEvaluationForm.tsx create mode 100644 src/components/SettingsPasswordForm.tsx create mode 100644 src/lib/evaluation-access.ts create mode 100644 src/lib/server-data.ts diff --git a/README.md b/README.md index 3b7bf04..4d44731 100644 --- a/README.md +++ b/README.md @@ -35,17 +35,16 @@ Open [http://localhost:3000](http://localhost:3000). - **2 templates**: Full 15-dimensions, Short 8-dimensions - **Admin user**: `admin@peaksys.local` (mock auth) -## API Routes +## API Routes (restantes) + +Les mutations (create, update, delete, share, etc.) sont gérées par **Server Actions**. Routes API restantes : | Route | Method | Description | |-------|--------|-------------| -| `/api/evaluations` | GET, POST | List / create evaluations | -| `/api/evaluations/[id]` | GET, PUT | Get / update evaluation | -| `/api/templates` | GET | List templates | | `/api/export/csv?id=` | GET | Export evaluation as CSV | | `/api/export/pdf?id=` | GET | Export evaluation as PDF | -| `/api/auth` | GET, POST | Mock auth | -| `/api/ai/suggest-followups` | POST | AI follow-up suggestions (stub) | +| `/api/auth/*` | — | NextAuth | +| `/api/auth/signup` | POST | Inscription | ## Export cURL Examples diff --git a/src/actions/admin.ts b/src/actions/admin.ts new file mode 100644 index 0000000..32a7d49 --- /dev/null +++ b/src/actions/admin.ts @@ -0,0 +1,43 @@ +"use server"; + +import { auth } from "@/auth"; +import { prisma } from "@/lib/db"; +import { revalidatePath } from "next/cache"; + +export type ActionResult = { success: true; data?: T } | { success: false; error: string }; + +export async function setUserRole(userId: string, role: "admin" | "evaluator"): Promise { + const session = await auth(); + if (session?.user?.role !== "admin") return { success: false, error: "Forbidden" }; + + if (!role || !["admin", "evaluator"].includes(role)) { + return { success: false, error: "Rôle invalide (admin | evaluator)" }; + } + + try { + await prisma.user.update({ where: { id: userId }, data: { role } }); + revalidatePath("/admin"); + return { success: true }; + } catch (e) { + console.error(e); + return { success: false, error: "Erreur" }; + } +} + +export async function deleteUser(userId: string): Promise { + const session = await auth(); + if (session?.user?.role !== "admin") return { success: false, error: "Forbidden" }; + + if (userId === session.user.id) { + return { success: false, error: "Impossible de supprimer votre propre compte" }; + } + + try { + await prisma.user.delete({ where: { id: userId } }); + revalidatePath("/admin"); + return { success: true }; + } catch (e) { + console.error(e); + return { success: false, error: "Erreur" }; + } +} diff --git a/src/actions/evaluations.ts b/src/actions/evaluations.ts new file mode 100644 index 0000000..7274091 --- /dev/null +++ b/src/actions/evaluations.ts @@ -0,0 +1,190 @@ +"use server"; + +import { auth } from "@/auth"; +import { prisma } from "@/lib/db"; +import { canAccessEvaluation } from "@/lib/evaluation-access"; +import { getEvaluation } from "@/lib/server-data"; +import { revalidatePath } from "next/cache"; + +export type ActionResult = { success: true; data?: T } | { success: false; error: string }; + +export async function fetchEvaluation(id: string): Promise>>> { + const session = await auth(); + if (!session?.user) return { success: false, error: "Non authentifié" }; + + const evaluation = await getEvaluation(id); + if (!evaluation) return { success: false, error: "Évaluation introuvable" }; + + return { success: true, data: evaluation }; +} + +export async function deleteEvaluation(id: string): Promise { + const session = await auth(); + if (!session?.user) return { success: false, error: "Non authentifié" }; + + const hasAccess = await canAccessEvaluation(id, session.user.id, session.user.role === "admin"); + if (!hasAccess) return { success: false, error: "Accès refusé" }; + + try { + await prisma.evaluation.delete({ where: { id } }); + revalidatePath("/dashboard"); + return { success: true }; + } catch (e) { + console.error(e); + return { success: false, error: "Erreur lors de la suppression" }; + } +} + +export async function createEvaluation(data: { + candidateName: string; + candidateRole: string; + candidateTeam?: string; + evaluationDate: string; + templateId: string; +}): Promise> { + const session = await auth(); + if (!session?.user) return { success: false, error: "Non authentifié" }; + + const { candidateName, candidateRole, candidateTeam, evaluationDate, templateId } = data; + if (!candidateName || !candidateRole || !evaluationDate || !templateId) { + return { success: false, error: "Champs requis manquants" }; + } + + try { + const evaluatorName = session.user.name || session.user.email || "Évaluateur"; + + const template = await prisma.template.findUnique({ + where: { id: templateId }, + include: { dimensions: { orderBy: { orderIndex: "asc" } } }, + }); + if (!template) return { success: false, error: "Template introuvable" }; + + const evaluation = await prisma.evaluation.create({ + data: { + candidateName, + candidateRole, + candidateTeam: candidateTeam || null, + evaluatorName, + evaluatorId: session.user.id, + evaluationDate: new Date(evaluationDate), + templateId, + status: "draft", + }, + }); + + for (const dim of template.dimensions) { + await prisma.dimensionScore.create({ + data: { evaluationId: evaluation.id, dimensionId: dim.id }, + }); + } + + revalidatePath("/dashboard"); + return { success: true, data: { id: evaluation.id } }; + } catch (e) { + console.error(e); + return { success: false, error: "Erreur lors de la création" }; + } +} + +export interface UpdateEvaluationInput { + candidateName?: string; + candidateRole?: string; + candidateTeam?: string | null; + evaluatorName?: string; + evaluationDate?: string; + status?: string; + findings?: string | null; + recommendations?: string | null; + isPublic?: boolean; + dimensionScores?: { + dimensionId: string; + evaluationId: string; + score: number | null; + justification?: string | null; + examplesObserved?: string | null; + confidence?: string | null; + candidateNotes?: string | null; + }[]; +} + +export async function updateEvaluation(id: string, data: UpdateEvaluationInput): Promise { + const session = await auth(); + if (!session?.user) return { success: false, error: "Non authentifié" }; + + const hasAccess = await canAccessEvaluation(id, session.user.id, session.user.role === "admin"); + if (!hasAccess) return { success: false, error: "Accès refusé" }; + + const existing = await prisma.evaluation.findUnique({ where: { id } }); + if (!existing) return { success: false, error: "Évaluation introuvable" }; + + try { + const { + candidateName, + candidateRole, + candidateTeam, + evaluatorName, + evaluationDate, + status, + findings, + recommendations, + isPublic, + dimensionScores, + } = data; + + const updateData: Record = {}; + if (candidateName != null) updateData.candidateName = candidateName; + if (candidateRole != null) updateData.candidateRole = candidateRole; + if (candidateTeam !== undefined) updateData.candidateTeam = candidateTeam; + if (evaluatorName != null) updateData.evaluatorName = evaluatorName; + if (evaluationDate != null) { + const d = new Date(evaluationDate); + if (!isNaN(d.getTime())) updateData.evaluationDate = d; + } + if (status != null) updateData.status = status; + if (findings != null) updateData.findings = findings; + if (recommendations != null) updateData.recommendations = recommendations; + if (typeof isPublic === "boolean") updateData.isPublic = isPublic; + + if (Object.keys(updateData).length > 0) { + await prisma.auditLog.create({ + data: { evaluationId: id, action: "updated", newValue: JSON.stringify(updateData) }, + }); + await prisma.evaluation.update({ where: { id }, data: updateData as Record }); + } + + if (dimensionScores && Array.isArray(dimensionScores)) { + for (const ds of dimensionScores) { + if (ds.dimensionId) { + await prisma.dimensionScore.upsert({ + where: { + evaluationId_dimensionId: { evaluationId: id, dimensionId: ds.dimensionId }, + }, + update: { + score: ds.score, + justification: ds.justification, + examplesObserved: ds.examplesObserved, + confidence: ds.confidence, + candidateNotes: ds.candidateNotes, + }, + create: { + evaluationId: id, + dimensionId: ds.dimensionId, + score: ds.score, + justification: ds.justification, + examplesObserved: ds.examplesObserved, + confidence: ds.confidence, + candidateNotes: ds.candidateNotes, + }, + }); + } + } + } + + revalidatePath(`/evaluations/${id}`); + revalidatePath("/dashboard"); + return { success: true }; + } catch (e) { + console.error(e); + return { success: false, error: e instanceof Error ? e.message : "Erreur lors de la sauvegarde" }; + } +} diff --git a/src/actions/password.ts b/src/actions/password.ts new file mode 100644 index 0000000..a77d3b4 --- /dev/null +++ b/src/actions/password.ts @@ -0,0 +1,45 @@ +"use server"; + +import bcrypt from "bcryptjs"; +import { auth } from "@/auth"; +import { prisma } from "@/lib/db"; + +export type ActionResult = { success: true } | { success: false; error: string }; + +export async function changePassword( + currentPassword: string, + newPassword: string +): Promise { + const session = await auth(); + if (!session?.user?.id) return { success: false, error: "Non authentifié" }; + + if (!currentPassword || !newPassword) { + return { success: false, error: "Mot de passe actuel et nouveau requis" }; + } + if (newPassword.length < 8) { + return { success: false, error: "Le nouveau mot de passe doit faire au moins 8 caractères" }; + } + + const user = await prisma.user.findUnique({ + where: { id: session.user.id }, + select: { passwordHash: true }, + }); + if (!user?.passwordHash) { + return { success: false, error: "Compte sans mot de passe (connexion SSO)" }; + } + + const ok = await bcrypt.compare(String(currentPassword), user.passwordHash); + if (!ok) return { success: false, error: "Mot de passe actuel incorrect" }; + + try { + const passwordHash = await bcrypt.hash(String(newPassword), 10); + await prisma.user.update({ + where: { id: session.user.id }, + data: { passwordHash }, + }); + return { success: true }; + } catch (e) { + console.error("Password change error:", e); + return { success: false, error: "Erreur lors du changement de mot de passe" }; + } +} diff --git a/src/actions/share.ts b/src/actions/share.ts new file mode 100644 index 0000000..84fadcd --- /dev/null +++ b/src/actions/share.ts @@ -0,0 +1,66 @@ +"use server"; + +import { auth } from "@/auth"; +import { prisma } from "@/lib/db"; +import { canAccessEvaluation } from "@/lib/evaluation-access"; +import { revalidatePath } from "next/cache"; + +export type ActionResult = { success: true; data?: T } | { success: false; error: string }; + +export async function addShare(evaluationId: string, userId: string): Promise { + const session = await auth(); + if (!session?.user) return { success: false, error: "Non authentifié" }; + + const hasAccess = await canAccessEvaluation( + evaluationId, + session.user.id, + session.user.role === "admin" + ); + if (!hasAccess) return { success: false, error: "Accès refusé" }; + + if (userId === session.user.id) return { success: false, error: "Vous avez déjà accès" }; + + const evaluation = await prisma.evaluation.findUnique({ + where: { id: evaluationId }, + select: { evaluatorId: true }, + }); + if (evaluation?.evaluatorId === userId) { + return { success: false, error: "L'évaluateur a déjà accès" }; + } + + try { + await prisma.evaluationShare.upsert({ + where: { evaluationId_userId: { evaluationId, userId } }, + create: { evaluationId, userId }, + update: {}, + }); + revalidatePath(`/evaluations/${evaluationId}`); + return { success: true }; + } catch (e) { + console.error(e); + return { success: false, error: "Erreur" }; + } +} + +export async function removeShare(evaluationId: string, userId: string): Promise { + const session = await auth(); + if (!session?.user) return { success: false, error: "Non authentifié" }; + + const hasAccess = await canAccessEvaluation( + evaluationId, + session.user.id, + session.user.role === "admin" + ); + if (!hasAccess) return { success: false, error: "Accès refusé" }; + + try { + await prisma.evaluationShare.deleteMany({ + where: { evaluationId, userId }, + }); + revalidatePath(`/evaluations/${evaluationId}`); + return { success: true }; + } catch (e) { + console.error(e); + return { success: false, error: "Erreur" }; + } +} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 8759253..f87f4e4 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -1,193 +1,10 @@ -"use client"; +import { redirect } from "next/navigation"; +import { getTemplates, getAdminUsers } from "@/lib/server-data"; +import { AdminClient } from "@/components/AdminClient"; -import { useState, useEffect } from "react"; -import { format } from "date-fns"; -import { useSession } from "next-auth/react"; -import { ConfirmModal } from "@/components/ConfirmModal"; +export default async function AdminPage() { + const [templates, users] = await Promise.all([getTemplates(), getAdminUsers()]); + if (!users) redirect("/auth/login"); -interface Template { - id: string; - name: string; - dimensions: { id: string; title: string; orderIndex: number }[]; -} - -interface User { - id: string; - email: string; - name: string | null; - role: string; - createdAt: string; -} - -export default function AdminPage() { - const [templates, setTemplates] = useState([]); - const { data: session } = useSession(); - const [users, setUsers] = useState([]); - const [loading, setLoading] = useState(true); - const [updatingId, setUpdatingId] = useState(null); - const [deleteTarget, setDeleteTarget] = useState(null); - - async function setRole(userId: string, role: "admin" | "evaluator") { - setUpdatingId(userId); - try { - const res = await fetch(`/api/admin/users/${userId}`, { - method: "PATCH", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ role }), - }); - if (res.ok) { - setUsers((prev) => prev.map((u) => (u.id === userId ? { ...u, role } : u))); - } else { - const data = await res.json().catch(() => ({})); - alert(data.error ?? "Erreur"); - } - } finally { - setUpdatingId(null); - } - } - - useEffect(() => { - Promise.all([ - fetch("/api/templates").then((r) => r.json()), - fetch("/api/admin/users").then((r) => r.json()), - ]) - .then(([templatesData, usersData]) => { - setTemplates(Array.isArray(templatesData) ? templatesData : []); - setUsers(Array.isArray(usersData) ? usersData : []); - }) - .catch(() => {}) - .finally(() => setLoading(false)); - }, []); - - if (loading) { - return
loading...
; - } - - return ( -
-

Admin

- -
-

Users

-
- - - - - - - - - - - - {users.map((u) => ( - - - - - - - - ))} - -
EmailNomRôleCréé le
{u.email}{u.name ?? "—"} - - {u.role} - - - {format(new Date(u.createdAt), "yyyy-MM-dd HH:mm")} - - - {u.role === "admin" ? ( - - ) : ( - - )} - {u.id !== session?.user?.id && ( - - )} - -
-
-
- - { - if (!deleteTarget) return; - const res = await fetch(`/api/admin/users/${deleteTarget.id}`, { method: "DELETE" }); - if (res.ok) { - setUsers((prev) => prev.filter((u) => u.id !== deleteTarget.id)); - setDeleteTarget(null); - } else { - const data = await res.json().catch(() => ({})); - alert(data.error ?? "Erreur"); - } - }} - onCancel={() => setDeleteTarget(null)} - /> - -
-

Modèles

-
- {templates.map((t) => ( -
-

{t.name}

-

- {t.dimensions.length} dim. -

-
    - {t.dimensions.slice(0, 5).map((d) => ( -
  • • {d.title}
  • - ))} - {t.dimensions.length > 5 && ( -
  • +{t.dimensions.length - 5}
  • - )} -
-
- ))} -
-
-
- ); + return ; } diff --git a/src/app/api/admin/users/[id]/route.ts b/src/app/api/admin/users/[id]/route.ts deleted file mode 100644 index f76126b..0000000 --- a/src/app/api/admin/users/[id]/route.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { auth } from "@/auth"; -import { prisma } from "@/lib/db"; - -export async function PATCH( - req: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - const session = await auth(); - if (session?.user?.role !== "admin") { - return NextResponse.json({ error: "Forbidden" }, { status: 403 }); - } - const { id } = await params; - const body = await req.json(); - const { role } = body; - - if (!role || !["admin", "evaluator"].includes(role)) { - return NextResponse.json({ error: "Rôle invalide (admin | evaluator)" }, { status: 400 }); - } - - const user = await prisma.user.update({ - where: { id }, - data: { role }, - }); - return NextResponse.json(user); -} - -export async function DELETE( - _req: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - const session = await auth(); - if (session?.user?.role !== "admin") { - return NextResponse.json({ error: "Forbidden" }, { status: 403 }); - } - const { id } = await params; - - if (id === session.user.id) { - return NextResponse.json({ error: "Impossible de supprimer votre propre compte" }, { status: 400 }); - } - - await prisma.user.delete({ where: { id } }); - return NextResponse.json({ ok: true }); -} diff --git a/src/app/api/admin/users/route.ts b/src/app/api/admin/users/route.ts deleted file mode 100644 index d1423d7..0000000 --- a/src/app/api/admin/users/route.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { NextResponse } from "next/server"; -import { auth } from "@/auth"; -import { prisma } from "@/lib/db"; - -export async function GET() { - const session = await auth(); - if (session?.user?.role !== "admin") { - return NextResponse.json({ error: "Forbidden" }, { status: 403 }); - } - const users = await prisma.user.findMany({ - orderBy: { createdAt: "desc" }, - select: { - id: true, - email: true, - name: true, - role: true, - createdAt: true, - }, - }); - return NextResponse.json(users); -} diff --git a/src/app/api/evaluations/[id]/route.ts b/src/app/api/evaluations/[id]/route.ts deleted file mode 100644 index 802174e..0000000 --- a/src/app/api/evaluations/[id]/route.ts +++ /dev/null @@ -1,241 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { Prisma } from "@prisma/client"; -import { auth } from "@/auth"; -import { prisma } from "@/lib/db"; - -async function canAccessEvaluation( - evaluationId: string, - userId: string, - isAdmin: boolean, - readOnly = false -) { - if (isAdmin) return true; - const eval_ = await prisma.evaluation.findUnique({ - where: { id: evaluationId }, - select: { evaluatorId: true, isPublic: true, sharedWith: { select: { userId: true } } }, - }); - if (!eval_) return false; - if (eval_.evaluatorId === userId) return true; - if (eval_.sharedWith.some((s) => s.userId === userId)) return true; - if (readOnly && eval_.isPublic) return true; - return false; -} - -export async function GET( - _req: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - try { - const session = await auth(); - if (!session?.user) { - return NextResponse.json({ error: "Non authentifié" }, { status: 401 }); - } - const { id } = await params; - const evaluation = await prisma.evaluation.findUnique({ - where: { id }, - include: { - template: { - include: { - dimensions: { orderBy: { orderIndex: "asc" } }, - }, - }, - dimensionScores: { include: { dimension: true } }, - sharedWith: { include: { user: { select: { id: true, email: true, name: true } } } }, - }, - }); - - if (!evaluation) { - return NextResponse.json({ error: "Evaluation not found" }, { status: 404 }); - } - - const hasAccess = await canAccessEvaluation( - id, - session.user.id, - session.user.role === "admin", - true // read-only: public evals accessibles en lecture - ); - if (!hasAccess) { - return NextResponse.json({ error: "Accès refusé" }, { status: 403 }); - } - - // Prisma ORM omits suggestedQuestions in some contexts — fetch via raw - const templateId = evaluation.templateId; - const dimsRaw = evaluation.template - ? ((await prisma.$queryRaw( - Prisma.sql`SELECT id, slug, title, rubric, "orderIndex", "suggestedQuestions" FROM "TemplateDimension" WHERE "templateId" = ${templateId} ORDER BY "orderIndex" ASC` - )) as { id: string; slug: string; title: string; rubric: string; orderIndex: number; suggestedQuestions: string | null }[]) - : []; - - const dimMap = new Map(dimsRaw.map((d) => [d.id, d])); - - const out = { - ...evaluation, - template: evaluation.template - ? { - ...evaluation.template, - dimensions: evaluation.template.dimensions.map((d) => { - const raw = dimMap.get(d.id); - return { - id: d.id, - slug: d.slug, - title: d.title, - rubric: d.rubric, - orderIndex: d.orderIndex, - suggestedQuestions: raw?.suggestedQuestions ?? d.suggestedQuestions, - }; - }), - } - : null, - dimensionScores: evaluation.dimensionScores.map((ds) => ({ - ...ds, - dimension: ds.dimension - ? { - id: ds.dimension.id, - slug: ds.dimension.slug, - title: ds.dimension.title, - rubric: ds.dimension.rubric, - suggestedQuestions: dimMap.get(ds.dimension.id)?.suggestedQuestions ?? ds.dimension.suggestedQuestions, - } - : null, - })), - }; - return NextResponse.json(out); - } catch (e) { - console.error(e); - return NextResponse.json({ error: "Failed to fetch evaluation" }, { status: 500 }); - } -} - -export async function PUT( - req: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - try { - const session = await auth(); - if (!session?.user) { - return NextResponse.json({ error: "Non authentifié" }, { status: 401 }); - } - const { id } = await params; - const body = await req.json(); - - const { candidateName, candidateRole, candidateTeam, evaluatorName, evaluationDate, status, findings, recommendations, dimensionScores, isPublic } = body; - - const existing = await prisma.evaluation.findUnique({ where: { id } }); - if (!existing) { - return NextResponse.json({ error: "Evaluation not found" }, { status: 404 }); - } - - const hasAccess = await canAccessEvaluation( - id, - session.user.id, - session.user.role === "admin" - ); - if (!hasAccess) { - return NextResponse.json({ error: "Accès refusé" }, { status: 403 }); - } - - const updateData: Record = {}; - if (candidateName != null) updateData.candidateName = candidateName; - if (candidateRole != null) updateData.candidateRole = candidateRole; - if (candidateTeam !== undefined) updateData.candidateTeam = candidateTeam || null; - if (evaluatorName != null) updateData.evaluatorName = evaluatorName; - if (evaluationDate != null) { - const d = new Date(evaluationDate); - if (!isNaN(d.getTime())) updateData.evaluationDate = d; - } - if (status != null) updateData.status = status; - if (findings != null) updateData.findings = findings; - if (recommendations != null) updateData.recommendations = recommendations; - if (typeof isPublic === "boolean") updateData.isPublic = isPublic; - - if (Object.keys(updateData).length > 0) { - await prisma.auditLog.create({ - data: { - evaluationId: id, - action: "updated", - newValue: JSON.stringify(updateData), - }, - }); - } - - if (Object.keys(updateData).length > 0) { - await prisma.evaluation.update({ - where: { id }, - data: updateData as Record, - }); - } - - if (dimensionScores && Array.isArray(dimensionScores)) { - for (const ds of dimensionScores) { - if (ds.dimensionId) { - await prisma.dimensionScore.upsert({ - where: { - evaluationId_dimensionId: { - evaluationId: id, - dimensionId: ds.dimensionId, - }, - }, - update: { - score: ds.score, - justification: ds.justification, - examplesObserved: ds.examplesObserved, - confidence: ds.confidence, - candidateNotes: ds.candidateNotes, - }, - create: { - evaluationId: id, - dimensionId: ds.dimensionId, - score: ds.score, - justification: ds.justification, - examplesObserved: ds.examplesObserved, - confidence: ds.confidence, - candidateNotes: ds.candidateNotes, - }, - }); - } - } - } - - const updated = await prisma.evaluation.findUnique({ - where: { id }, - include: { - template: { include: { dimensions: { orderBy: { orderIndex: "asc" } } } }, - dimensionScores: { include: { dimension: true } }, - }, - }); - - return NextResponse.json(updated); - } catch (e) { - console.error(e); - const msg = e instanceof Error ? e.message : "Failed to update evaluation"; - return NextResponse.json({ error: msg }, { status: 500 }); - } -} - -export async function DELETE( - _req: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - try { - const session = await auth(); - if (!session?.user) { - return NextResponse.json({ error: "Non authentifié" }, { status: 401 }); - } - const { id } = await params; - - const hasAccess = await canAccessEvaluation( - id, - session.user.id, - session.user.role === "admin" - ); - if (!hasAccess) { - return NextResponse.json({ error: "Accès refusé" }, { status: 403 }); - } - - await prisma.evaluation.delete({ where: { id } }); - return NextResponse.json({ ok: true }); - } catch (e) { - console.error(e); - return NextResponse.json({ error: "Failed to delete evaluation" }, { status: 500 }); - } -} diff --git a/src/app/api/evaluations/[id]/share/[userId]/route.ts b/src/app/api/evaluations/[id]/share/[userId]/route.ts deleted file mode 100644 index 5c164ec..0000000 --- a/src/app/api/evaluations/[id]/share/[userId]/route.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { auth } from "@/auth"; -import { prisma } from "@/lib/db"; - -async function canAccessEvaluation(evaluationId: string, userId: string, isAdmin: boolean) { - if (isAdmin) return true; - const eval_ = await prisma.evaluation.findUnique({ - where: { id: evaluationId }, - select: { evaluatorId: true, sharedWith: { select: { userId: true } } }, - }); - if (!eval_) return false; - if (eval_.evaluatorId === userId) return true; - if (eval_.sharedWith.some((s) => s.userId === userId)) return true; - return false; -} - -export async function DELETE( - _req: NextRequest, - { params }: { params: Promise<{ id: string; userId: string }> } -) { - try { - const session = await auth(); - if (!session?.user) { - return NextResponse.json({ error: "Non authentifié" }, { status: 401 }); - } - const { id, userId } = await params; - - const hasAccess = await canAccessEvaluation( - id, - session.user.id, - session.user.role === "admin" - ); - if (!hasAccess) { - return NextResponse.json({ error: "Accès refusé" }, { status: 403 }); - } - - await prisma.evaluationShare.deleteMany({ - where: { evaluationId: id, userId }, - }); - - return NextResponse.json({ ok: true }); - } catch (e) { - console.error(e); - return NextResponse.json({ error: "Erreur" }, { status: 500 }); - } -} diff --git a/src/app/api/evaluations/[id]/share/route.ts b/src/app/api/evaluations/[id]/share/route.ts deleted file mode 100644 index ca4232c..0000000 --- a/src/app/api/evaluations/[id]/share/route.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { auth } from "@/auth"; -import { prisma } from "@/lib/db"; - -async function canAccessEvaluation(evaluationId: string, userId: string, isAdmin: boolean) { - if (isAdmin) return true; - const eval_ = await prisma.evaluation.findUnique({ - where: { id: evaluationId }, - select: { evaluatorId: true, sharedWith: { select: { userId: true } } }, - }); - if (!eval_) return false; - if (eval_.evaluatorId === userId) return true; - if (eval_.sharedWith.some((s) => s.userId === userId)) return true; - return false; -} - -export async function GET( - _req: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - try { - const session = await auth(); - if (!session?.user) { - return NextResponse.json({ error: "Non authentifié" }, { status: 401 }); - } - const { id } = await params; - - const hasAccess = await canAccessEvaluation( - id, - session.user.id, - session.user.role === "admin" - ); - if (!hasAccess) { - return NextResponse.json({ error: "Accès refusé" }, { status: 403 }); - } - - const sharedWith = await prisma.evaluationShare.findMany({ - where: { evaluationId: id }, - include: { user: { select: { id: true, email: true, name: true } } }, - }); - return NextResponse.json(sharedWith); - } catch (e) { - console.error(e); - return NextResponse.json({ error: "Erreur" }, { status: 500 }); - } -} - -export async function POST( - req: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - try { - const session = await auth(); - if (!session?.user) { - return NextResponse.json({ error: "Non authentifié" }, { status: 401 }); - } - const { id } = await params; - const body = await req.json(); - const { email, userId } = body; - - if (!userId && !email) { - return NextResponse.json({ error: "userId ou email requis" }, { status: 400 }); - } - - let user; - if (userId && typeof userId === "string") { - user = await prisma.user.findUnique({ where: { id: userId } }); - } else if (email && typeof email === "string") { - user = await prisma.user.findUnique({ - where: { email: String(email).toLowerCase().trim() }, - }); - } - if (!user) { - return NextResponse.json({ error: "Utilisateur introuvable" }, { status: 404 }); - } - - const hasAccess = await canAccessEvaluation( - id, - session.user.id, - session.user.role === "admin" - ); - if (!hasAccess) { - return NextResponse.json({ error: "Accès refusé" }, { status: 403 }); - } - - if (user.id === session.user.id) { - return NextResponse.json({ error: "Vous avez déjà accès" }, { status: 400 }); - } - - const evaluation = await prisma.evaluation.findUnique({ - where: { id }, - select: { evaluatorId: true }, - }); - if (evaluation?.evaluatorId === user.id) { - return NextResponse.json({ error: "L'évaluateur a déjà accès" }, { status: 400 }); - } - - await prisma.evaluationShare.upsert({ - where: { - evaluationId_userId: { evaluationId: id, userId: user.id }, - }, - create: { evaluationId: id, userId: user.id }, - update: {}, - }); - - return NextResponse.json({ ok: true, user: { id: user.id, email: user.email, name: user.name } }); - } catch (e) { - console.error(e); - return NextResponse.json({ error: "Erreur" }, { status: 500 }); - } -} diff --git a/src/app/api/evaluations/route.ts b/src/app/api/evaluations/route.ts deleted file mode 100644 index 57bb5a9..0000000 --- a/src/app/api/evaluations/route.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { auth } from "@/auth"; -import { prisma } from "@/lib/db"; - -export async function GET(req: NextRequest) { - try { - const session = await auth(); - if (!session?.user) { - return NextResponse.json({ error: "Non authentifié" }, { status: 401 }); - } - const { searchParams } = new URL(req.url); - const status = searchParams.get("status"); - const templateId = searchParams.get("templateId"); - - const isAdmin = session.user.role === "admin"; - const userId = session.user.id; - - const evaluations = await prisma.evaluation.findMany({ - where: { - ...(status && { status }), - ...(templateId && { templateId }), - ...(!isAdmin && { - OR: [ - { evaluatorId: userId }, - { sharedWith: { some: { userId } } }, - { isPublic: true }, - ], - }), - }, - include: { - template: { include: { dimensions: { orderBy: { orderIndex: "asc" } } } }, - dimensionScores: { include: { dimension: true } }, - }, - orderBy: { evaluationDate: "desc" }, - }); - - return NextResponse.json(evaluations); - } catch (e) { - console.error(e); - return NextResponse.json({ error: "Failed to fetch evaluations" }, { status: 500 }); - } -} - -export async function POST(req: NextRequest) { - try { - const session = await auth(); - if (!session?.user) { - return NextResponse.json({ error: "Non authentifié" }, { status: 401 }); - } - const body = await req.json(); - const { candidateName, candidateRole, candidateTeam, evaluationDate, templateId } = body; - - if (!candidateName || !candidateRole || !evaluationDate || !templateId) { - return NextResponse.json( - { error: "Missing required fields: candidateName, candidateRole, evaluationDate, templateId" }, - { status: 400 } - ); - } - - const evaluatorName = session.user.name || session.user.email || "Évaluateur"; - - const template = await prisma.template.findUnique({ - where: { id: templateId }, - include: { dimensions: { orderBy: { orderIndex: "asc" } } }, - }); - if (!template) { - return NextResponse.json({ error: "Template not found" }, { status: 404 }); - } - - const evaluation = await prisma.evaluation.create({ - data: { - candidateName, - candidateRole, - candidateTeam: candidateTeam || null, - evaluatorName, - evaluatorId: session.user.id, - evaluationDate: new Date(evaluationDate), - templateId, - status: "draft", - }, - include: { - template: { include: { dimensions: { orderBy: { orderIndex: "asc" } } } }, - dimensionScores: { include: { dimension: true } }, - }, - }); - - // Create empty dimension scores for each template dimension - for (const dim of template.dimensions) { - await prisma.dimensionScore.create({ - data: { - evaluationId: evaluation.id, - dimensionId: dim.id, - }, - }); - } - - const updated = await prisma.evaluation.findUnique({ - where: { id: evaluation.id }, - include: { - template: { include: { dimensions: { orderBy: { orderIndex: "asc" } } } }, - dimensionScores: { include: { dimension: true } }, - }, - }); - - return NextResponse.json(updated); - } catch (e) { - console.error(e); - return NextResponse.json({ error: "Failed to create evaluation" }, { status: 500 }); - } -} diff --git a/src/app/api/templates/data/route.ts b/src/app/api/templates/data/route.ts deleted file mode 100644 index d1ebd04..0000000 --- a/src/app/api/templates/data/route.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { NextResponse } from "next/server"; -import { prisma } from "@/lib/db"; - -/** Returns templates in the canonical JSON format: { templates: [{ id, name, dimensions: [{ id, title, rubric }] }] } */ -export async function GET() { - try { - const templates = await prisma.template.findMany({ - include: { - dimensions: { orderBy: { orderIndex: "asc" } }, - }, - }); - const data = { - templates: templates.map((t) => ({ - id: t.id, - name: t.name, - dimensions: t.dimensions.map((d) => ({ - id: d.slug, - title: d.title, - rubric: d.rubric, - suggestedQuestions: d.suggestedQuestions, - })), - })), - }; - return NextResponse.json(data); - } catch (e) { - console.error(e); - return NextResponse.json({ error: "Failed to fetch templates" }, { status: 500 }); - } -} diff --git a/src/app/api/templates/route.ts b/src/app/api/templates/route.ts deleted file mode 100644 index c69635f..0000000 --- a/src/app/api/templates/route.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { NextResponse } from "next/server"; -import { Prisma } from "@prisma/client"; -import { prisma } from "@/lib/db"; - -export async function GET() { - try { - const templates = await prisma.template.findMany({ - include: { - dimensions: { orderBy: { orderIndex: "asc" } }, - }, - }); - // Prisma ORM omits suggestedQuestions — enrich via raw - const dimsRaw = (await prisma.$queryRaw( - Prisma.sql`SELECT id, "templateId", slug, title, rubric, "orderIndex", "suggestedQuestions" FROM "TemplateDimension" ORDER BY "templateId", "orderIndex"` - )) as { id: string; templateId: string; slug: string; title: string; rubric: string; orderIndex: number; suggestedQuestions: string | null }[]; - const dimMap = new Map(dimsRaw.map((d) => [d.id, d])); - const out = templates.map((t) => ({ - ...t, - dimensions: t.dimensions.map((d) => ({ - ...d, - suggestedQuestions: dimMap.get(d.id)?.suggestedQuestions ?? d.suggestedQuestions, - })), - })); - return NextResponse.json(out); - } catch (e) { - console.error(e); - return NextResponse.json({ error: "Failed to fetch templates" }, { status: 500 }); - } -} diff --git a/src/app/api/users/me/password/route.ts b/src/app/api/users/me/password/route.ts deleted file mode 100644 index dcf8b0d..0000000 --- a/src/app/api/users/me/password/route.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { auth } from "@/auth"; -import { prisma } from "@/lib/db"; -import bcrypt from "bcryptjs"; - -export async function PATCH(req: NextRequest) { - const session = await auth(); - if (!session?.user?.id) { - return NextResponse.json({ error: "Non authentifié" }, { status: 401 }); - } - - try { - const { currentPassword, newPassword } = await req.json(); - if (!currentPassword || !newPassword) { - return NextResponse.json( - { error: "Mot de passe actuel et nouveau requis" }, - { status: 400 } - ); - } - if (newPassword.length < 8) { - return NextResponse.json( - { error: "Le nouveau mot de passe doit faire au moins 8 caractères" }, - { status: 400 } - ); - } - - const user = await prisma.user.findUnique({ - where: { id: session.user.id }, - select: { passwordHash: true }, - }); - if (!user?.passwordHash) { - return NextResponse.json( - { error: "Compte sans mot de passe (connexion SSO)" }, - { status: 400 } - ); - } - - const ok = await bcrypt.compare(String(currentPassword), user.passwordHash); - if (!ok) { - return NextResponse.json( - { error: "Mot de passe actuel incorrect" }, - { status: 401 } - ); - } - - const passwordHash = await bcrypt.hash(String(newPassword), 10); - await prisma.user.update({ - where: { id: session.user.id }, - data: { passwordHash }, - }); - - return NextResponse.json({ ok: true }); - } catch (e) { - console.error("Password change error:", e); - return NextResponse.json( - { error: "Erreur lors du changement de mot de passe" }, - { status: 500 } - ); - } -} diff --git a/src/app/api/users/route.ts b/src/app/api/users/route.ts deleted file mode 100644 index 74ecea4..0000000 --- a/src/app/api/users/route.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { NextResponse } from "next/server"; -import { auth } from "@/auth"; -import { prisma } from "@/lib/db"; - -/** Liste des utilisateurs (pour partage d'évaluations) — accessible à tout utilisateur connecté */ -export async function GET() { - const session = await auth(); - if (!session?.user) { - return NextResponse.json({ error: "Non authentifié" }, { status: 401 }); - } - const users = await prisma.user.findMany({ - orderBy: { email: "asc" }, - select: { - id: true, - email: true, - name: true, - }, - }); - return NextResponse.json(users); -} diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index a5a6cce..609fe86 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -1,167 +1,10 @@ -"use client"; +import { redirect } from "next/navigation"; +import { getEvaluations } from "@/lib/server-data"; +import { DashboardClient } from "@/components/DashboardClient"; -import { useState, useEffect } from "react"; -import Link from "next/link"; -import { format } from "date-fns"; -import { ConfirmModal } from "@/components/ConfirmModal"; -import { RadarChart } from "@/components/RadarChart"; +export default async function DashboardPage() { + const evaluations = await getEvaluations(); + if (!evaluations) redirect("/auth/login"); -interface Dimension { - id: string; - title: string; -} - -interface EvalRow { - id: string; - candidateName: string; - candidateRole: string; - candidateTeam?: string | null; - evaluatorName: string; - evaluationDate: string; - template?: { name: string; dimensions?: Dimension[] }; - status: string; - dimensionScores?: { dimensionId: string; score: number | null; dimension?: { title: string } }[]; -} - -function buildRadarData(e: EvalRow) { - const dimensions = e.template?.dimensions ?? []; - const scoreMap = new Map( - (e.dimensionScores ?? []).map((ds) => [ds.dimensionId, ds]) - ); - return dimensions - .filter((dim) => !(dim.title ?? "").startsWith("[Optionnel]")) - .map((dim) => { - const ds = scoreMap.get(dim.id); - const score = ds?.score; - if (score == null) return null; - const s = Number(score); - if (Number.isNaN(s) || s < 0 || s > 5) return null; - const title = dim.title ?? ""; - return { - dimension: title.length > 12 ? title.slice(0, 12) + "…" : title, - score: s, - fullMark: 5, - }; - }) - .filter((d): d is { dimension: string; score: number; fullMark: number } => d != null); -} - -export default function DashboardPage() { - const [evaluations, setEvaluations] = useState([]); - const [loading, setLoading] = useState(true); - const [deleteTarget, setDeleteTarget] = useState(null); - - useEffect(() => { - fetch("/api/evaluations") - .then((r) => r.json()) - .then(setEvaluations) - .catch(() => []) - .finally(() => setLoading(false)); - }, []); - - return ( -
-
-

Évaluations

- - + nouvelle - -
- - {loading ? ( -
loading...
- ) : evaluations.length === 0 ? ( -
- Aucune évaluation.{" "} - - Créer - -
- ) : ( -
- {evaluations.map((e) => { - const radarData = buildRadarData(e); - return ( - -
-
-
-

{e.candidateName}

-

- {e.candidateRole} - {e.candidateTeam && ` · ${e.candidateTeam}`} -

-
- - {e.status === "submitted" ? "ok" : "draft"} - -
-
- {e.evaluatorName} - {format(new Date(e.evaluationDate), "yyyy-MM-dd")} - {e.template?.name ?? ""} -
-
- {radarData.length > 0 ? ( - - ) : ( -
- pas de scores -
- )} -
-
-
- → ouvrir - -
- - ); - })} -
- )} - - { - if (!deleteTarget) return; - const res = await fetch(`/api/evaluations/${deleteTarget.id}`, { method: "DELETE" }); - if (res.ok) setEvaluations((prev) => prev.filter((x) => x.id !== deleteTarget.id)); - else alert("Erreur lors de la suppression"); - }} - onCancel={() => setDeleteTarget(null)} - /> -
- ); + return ; } diff --git a/src/app/evaluations/[id]/page.tsx b/src/app/evaluations/[id]/page.tsx index 26ebc09..ea808f9 100644 --- a/src/app/evaluations/[id]/page.tsx +++ b/src/app/evaluations/[id]/page.tsx @@ -1,215 +1,20 @@ -"use client"; - -import { useState, useEffect, useCallback } from "react"; import Link from "next/link"; -import { useParams, useRouter } from "next/navigation"; -import { CandidateForm } from "@/components/CandidateForm"; -import { DimensionCard } from "@/components/DimensionCard"; -import { RadarChart } from "@/components/RadarChart"; -import { ExportModal } from "@/components/ExportModal"; -import { ShareModal } from "@/components/ShareModal"; -import { ConfirmModal } from "@/components/ConfirmModal"; -import { generateFindings, generateRecommendations, computeAverageScore } from "@/lib/export-utils"; +import { redirect } from "next/navigation"; +import { getEvaluation, getTemplates, getUsers } from "@/lib/server-data"; +import { EvaluationEditor } from "@/components/EvaluationEditor"; -interface Dimension { - id: string; - slug: string; - title: string; - rubric: string; - suggestedQuestions?: string | null; +interface PageProps { + params: Promise<{ id: string }>; } -interface DimensionScore { - id: string; - dimensionId: string; - score: number | null; - justification: string | null; - examplesObserved: string | null; - confidence: string | null; - candidateNotes: string | null; - dimension: Dimension; -} +export default async function EvaluationDetailPage({ params }: PageProps) { + const { id } = await params; + const [evaluation, templates, users] = await Promise.all([ + getEvaluation(id), + getTemplates(), + getUsers(), + ]); -interface Evaluation { - id: string; - candidateName: string; - candidateRole: string; - candidateTeam?: string | null; - evaluatorName: string; - evaluatorId?: string | null; - evaluationDate: string; - templateId: string; - template: { id: string; name: string; dimensions: Dimension[] }; - status: string; - findings: string | null; - recommendations: string | null; - dimensionScores: DimensionScore[]; - sharedWith?: { id: string; user: { id: string; email: string; name: string | null } }[]; - isPublic?: boolean; -} - -export default function EvaluationDetailPage() { - const params = useParams(); - const router = useRouter(); - const id = params.id as string; - const [evaluation, setEvaluation] = useState(null); - const [loading, setLoading] = useState(true); - const [saving, setSaving] = useState(false); - const [templates, setTemplates] = useState<{ id: string; name: string }[]>([]); - const [exportOpen, setExportOpen] = useState(false); - const [shareOpen, setShareOpen] = useState(false); - const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); - const [collapseAllTrigger, setCollapseAllTrigger] = useState(0); - const [users, setUsers] = useState<{ id: string; email: string; name: string | null }[]>([]); - - const fetchEval = useCallback(() => { - setLoading(true); - Promise.all([ - fetch(`/api/evaluations/${id}`).then((r) => r.json()), - fetch("/api/templates").then((r) => r.json()), - fetch("/api/users").then((r) => r.json()), - ]) - .then(([evalData, templatesData, usersData]) => { - setTemplates(Array.isArray(templatesData) ? templatesData : []); - setUsers(Array.isArray(usersData) ? usersData : []); - if (evalData?.error) { - setEvaluation(null); - return; - } - try { - if (evalData?.template?.dimensions?.length > 0 && Array.isArray(templatesData)) { - const tmpl = templatesData.find((t: { id: string }) => t.id === evalData.templateId); - if (tmpl?.dimensions?.length) { - const dimMap = new Map(tmpl.dimensions.map((d: { id: string; suggestedQuestions?: string | null }) => [d.id, d])); - evalData.template.dimensions = evalData.template.dimensions.map((d: { id: string; suggestedQuestions?: string | null }) => ({ - ...d, - suggestedQuestions: d.suggestedQuestions ?? (dimMap.get(d.id) as { suggestedQuestions?: string | null } | undefined)?.suggestedQuestions, - })); - } - } - } catch { - /* merge failed, use evalData as-is */ - } - setEvaluation({ ...evalData, dimensionScores: evalData.dimensionScores ?? [] }); - }) - .catch(() => setEvaluation(null)) - .finally(() => setLoading(false)); - }, [id]); - - useEffect(() => { - fetchEval(); - }, [fetchEval]); - - // Draft backup to localStorage (debounced, for offline resilience) - useEffect(() => { - if (!evaluation || !id) return; - const t = setTimeout(() => { - try { - localStorage.setItem( - `eval-draft-${id}`, - JSON.stringify({ ...evaluation, evaluationDate: evaluation.evaluationDate }) - ); - } catch { - /* ignore */ - } - }, 2000); - return () => clearTimeout(t); - }, [evaluation, id]); - - const handleFormChange = (field: string, value: string) => { - if (!evaluation) return; - setEvaluation((e) => (e ? { ...e, [field]: value } : null)); - }; - - const handleScoreChange = (dimensionId: string, data: Partial) => { - if (!evaluation) return; - setEvaluation((e) => { - if (!e) return null; - const existing = e.dimensionScores.find((ds) => ds.dimensionId === dimensionId); - const dim = e.template?.dimensions?.find((d) => d.id === dimensionId); - const scores = existing - ? e.dimensionScores.map((ds) => - ds.dimensionId === dimensionId ? { ...ds, ...data } : ds - ) - : [ - ...e.dimensionScores, - { - id: `temp-${dimensionId}`, - dimensionId, - score: (data as { score?: number }).score ?? null, - justification: (data as { justification?: string }).justification ?? null, - examplesObserved: (data as { examplesObserved?: string }).examplesObserved ?? null, - confidence: (data as { confidence?: string }).confidence ?? null, - candidateNotes: (data as { candidateNotes?: string }).candidateNotes ?? null, - dimension: dim ?? { id: dimensionId, slug: "", title: "", rubric: "" }, - }, - ]; - const next = { ...e, dimensionScores: scores }; - if (data.score !== undefined) { - setTimeout(() => handleSave(next, { skipRefresh: true }), 0); - } - return next; - }); - }; - - const handleSave = async (evalOverride?: Evaluation | null, options?: { skipRefresh?: boolean }) => { - const toSave = evalOverride ?? evaluation; - if (!toSave) return; - setSaving(true); - try { - const res = await fetch(`/api/evaluations/${id}`, { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ - candidateName: toSave.candidateName, - candidateRole: toSave.candidateRole, - candidateTeam: toSave.candidateTeam ?? null, - evaluatorName: toSave.evaluatorName, - evaluationDate: typeof toSave.evaluationDate === "string" ? toSave.evaluationDate : new Date(toSave.evaluationDate).toISOString(), - status: toSave.status, - findings: toSave.findings, - recommendations: toSave.recommendations, - isPublic: toSave.isPublic ?? false, - dimensionScores: (toSave.dimensionScores ?? []).map((ds) => ({ - dimensionId: ds.dimensionId, - evaluationId: id, - score: ds.score, - justification: ds.justification, - examplesObserved: ds.examplesObserved, - confidence: ds.confidence, - candidateNotes: ds.candidateNotes, - })), - }), - }); - if (res.ok) { - if (!options?.skipRefresh) fetchEval(); - } else { - const data = await res.json().catch(() => ({})); - alert(data.error ?? `Save failed (${res.status})`); - } - } catch (err) { - console.error("Save error:", err); - alert("Erreur lors de la sauvegarde"); - } finally { - setSaving(false); - } - }; - - const handleGenerateFindings = () => { - if (!evaluation) return; - const findings = generateFindings(evaluation.dimensionScores ?? []); - const recommendations = generateRecommendations(evaluation.dimensionScores ?? []); - setEvaluation((e) => (e ? { ...e, findings, recommendations } : null)); - }; - - const allFives = evaluation?.dimensionScores?.every( - (ds) => ds.score === 5 && (!ds.justification || ds.justification.trim() === "") - ); - const showAllFivesWarning = allFives && evaluation?.status === "submitted"; - - if (loading) { - return
loading...
; - } if (!evaluation) { return (
@@ -221,234 +26,23 @@ export default function EvaluationDetailPage() { ); } - const dimensions = evaluation.template?.dimensions ?? []; - const dimensionScores = evaluation.dimensionScores ?? []; - const scoreMap = new Map(dimensionScores.map((ds) => [ds.dimensionId, ds])); - const radarData = dimensions - .filter((dim) => !(dim.title ?? "").startsWith("[Optionnel]")) - .map((dim) => { - const ds = scoreMap.get(dim.id); - const score = ds?.score; - if (score == null) return null; - const title = dim.title ?? ""; - const s = Number(score); - if (Number.isNaN(s) || s < 0 || s > 5) return null; - return { - dimension: title.length > 12 ? title.slice(0, 12) + "…" : title, - score: s, - fullMark: 5, - }; - }) - .filter((d): d is { dimension: string; score: number; fullMark: number } => d != null); - const avgScore = computeAverageScore(dimensionScores); + if (!users) redirect("/auth/login"); + + const templatesForEditor = templates.map((t) => ({ + id: t.id, + name: t.name, + dimensions: t.dimensions.map((d) => ({ + id: d.id, + suggestedQuestions: d.suggestedQuestions, + })), + })); return ( -
-
-

- {evaluation.candidateName} - {evaluation.candidateTeam && ( - ({evaluation.candidateTeam}) - )} - / {evaluation.candidateRole} -

-
- - - - -
-
- - {showAllFivesWarning && ( -
- ⚠ Tous les scores = 5 sans justification -
- )} - -
-
-

- Session -

- -
- -
-
-

Dimensions

- -
- -
- {dimensions.map((dim, i) => ( -
- -
- ))} -
-
- -
-

Synthèse

-

- Moyenne {avgScore.toFixed(1)}/5 -

- -
-
- -