Refactor evaluation and admin pages to use server actions for data fetching, enhancing performance and simplifying state management. Update README to reflect API route changes and remove deprecated API endpoints for users and evaluations.
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m7s

This commit is contained in:
Julien Froidefond
2026-02-20 14:08:18 +01:00
parent 2ef9b4d6f9
commit aab8a192d4
28 changed files with 1511 additions and 1739 deletions

View File

@@ -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

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

@@ -0,0 +1,43 @@
"use server";
import { auth } from "@/auth";
import { prisma } from "@/lib/db";
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> {
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<ActionResult> {
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" };
}
}

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

@@ -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<T = void> = { success: true; data?: T } | { success: false; error: string };
export async function fetchEvaluation(id: string): Promise<ActionResult<Awaited<ReturnType<typeof getEvaluation>>>> {
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<ActionResult> {
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<ActionResult<{ id: string }>> {
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<ActionResult> {
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<string, unknown> = {};
if (candidateName != null) updateData.candidateName = candidateName;
if (candidateRole != null) updateData.candidateRole = candidateRole;
if (candidateTeam !== undefined) updateData.candidateTeam = candidateTeam;
if (evaluatorName != null) updateData.evaluatorName = evaluatorName;
if (evaluationDate != null) {
const d = new Date(evaluationDate);
if (!isNaN(d.getTime())) updateData.evaluationDate = d;
}
if (status != null) updateData.status = status;
if (findings != null) updateData.findings = findings;
if (recommendations != null) updateData.recommendations = recommendations;
if (typeof isPublic === "boolean") updateData.isPublic = isPublic;
if (Object.keys(updateData).length > 0) {
await prisma.auditLog.create({
data: { evaluationId: id, action: "updated", newValue: JSON.stringify(updateData) },
});
await prisma.evaluation.update({ where: { id }, data: updateData as Record<string, unknown> });
}
if (dimensionScores && Array.isArray(dimensionScores)) {
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" };
}
}

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

@@ -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<ActionResult> {
const session = await auth();
if (!session?.user?.id) return { success: false, error: "Non authentifié" };
if (!currentPassword || !newPassword) {
return { success: false, error: "Mot de passe actuel et nouveau requis" };
}
if (newPassword.length < 8) {
return { success: false, error: "Le nouveau mot de passe doit faire au moins 8 caractères" };
}
const user = await prisma.user.findUnique({
where: { id: session.user.id },
select: { passwordHash: true },
});
if (!user?.passwordHash) {
return { success: false, error: "Compte sans mot de passe (connexion SSO)" };
}
const ok = await bcrypt.compare(String(currentPassword), user.passwordHash);
if (!ok) return { success: false, error: "Mot de passe actuel incorrect" };
try {
const passwordHash = await bcrypt.hash(String(newPassword), 10);
await prisma.user.update({
where: { id: session.user.id },
data: { passwordHash },
});
return { success: true };
} catch (e) {
console.error("Password change error:", e);
return { success: false, error: "Erreur lors du changement de mot de passe" };
}
}

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

@@ -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<T = void> = { success: true; data?: T } | { success: false; error: string };
export async function addShare(evaluationId: string, userId: string): Promise<ActionResult> {
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<ActionResult> {
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" };
}
}

View File

@@ -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<Template[]>([]);
const { data: session } = useSession();
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [updatingId, setUpdatingId] = useState<string | null>(null);
const [deleteTarget, setDeleteTarget] = useState<User | null>(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 <div className="py-12 text-center font-mono text-xs text-zinc-600 dark:text-zinc-500">loading...</div>;
}
return (
<div>
<h1 className="mb-6 font-mono text-lg font-medium text-zinc-800 dark:text-zinc-200">Admin</h1>
<section>
<h2 className="mb-4 font-mono text-xs text-zinc-600 dark:text-zinc-500">Users</h2>
<div className="overflow-hidden rounded-lg border border-zinc-200 dark:border-zinc-600 bg-white dark:bg-zinc-800 shadow-sm dark:shadow-none">
<table className="min-w-full">
<thead>
<tr className="border-b border-zinc-200 dark:border-zinc-600 bg-zinc-50 dark:bg-zinc-700/80">
<th className="px-4 py-2.5 text-left font-mono text-xs text-zinc-600 dark:text-zinc-400">Email</th>
<th className="px-4 py-2.5 text-left font-mono text-xs text-zinc-600 dark:text-zinc-400">Nom</th>
<th className="px-4 py-2.5 text-left font-mono text-xs text-zinc-600 dark:text-zinc-400">Rôle</th>
<th className="px-4 py-2.5 text-left font-mono text-xs text-zinc-600 dark:text-zinc-400">Créé le</th>
<th className="px-4 py-2.5 text-right font-mono text-xs text-zinc-600 dark:text-zinc-400"></th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr key={u.id} className="border-b border-zinc-200 dark:border-zinc-600/50 last:border-0">
<td className="px-4 py-2.5 text-sm text-zinc-800 dark:text-zinc-200">{u.email}</td>
<td className="px-4 py-2.5 text-sm text-zinc-600 dark:text-zinc-400">{u.name ?? "—"}</td>
<td className="px-4 py-2.5">
<span
className={`font-mono text-xs px-1.5 py-0.5 rounded ${
u.role === "admin" ? "bg-cyan-500/20 text-cyan-600 dark:text-cyan-400" : "bg-zinc-200 dark:bg-zinc-700 text-zinc-600 dark:text-zinc-400"
}`}
>
{u.role}
</span>
</td>
<td className="px-4 py-2.5 font-mono text-xs text-zinc-500 dark:text-zinc-400">
{format(new Date(u.createdAt), "yyyy-MM-dd HH:mm")}
</td>
<td className="px-4 py-2.5 text-right">
<span className="inline-flex items-center gap-2">
{u.role === "admin" ? (
<button
type="button"
onClick={() => setRole(u.id, "evaluator")}
disabled={updatingId === u.id}
className="font-mono text-xs text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300 disabled:opacity-50"
title="Rétrograder en évaluateur"
>
{updatingId === u.id ? "..." : "rétrograder"}
</button>
) : (
<button
type="button"
onClick={() => setRole(u.id, "admin")}
disabled={updatingId === u.id}
className="font-mono text-xs text-cyan-600 dark:text-cyan-400 hover:text-cyan-500 disabled:opacity-50"
title="Promouvoir admin"
>
{updatingId === u.id ? "..." : "promouvoir admin"}
</button>
)}
{u.id !== session?.user?.id && (
<button
type="button"
onClick={() => setDeleteTarget(u)}
className="font-mono text-xs text-red-500 hover:text-red-400"
title="Supprimer"
>
supprimer
</button>
)}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
<ConfirmModal
isOpen={!!deleteTarget}
title="Supprimer l'utilisateur"
message={
deleteTarget
? `Supprimer ${deleteTarget.name || deleteTarget.email} ? Les évaluations créées par cet utilisateur resteront (évaluateur mis à null).`
: ""
}
confirmLabel="Supprimer"
cancelLabel="Annuler"
variant="danger"
onConfirm={async () => {
if (!deleteTarget) return;
const 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)}
/>
<section className="mt-8">
<h2 className="mb-4 font-mono text-xs text-zinc-600 dark:text-zinc-500">Modèles</h2>
<div className="space-y-3">
{templates.map((t) => (
<div
key={t.id}
className="rounded-lg border border-zinc-200 dark:border-zinc-600 bg-white dark:bg-zinc-800 p-4 shadow-sm dark:shadow-none"
>
<h3 className="font-medium text-zinc-800 dark:text-zinc-200">{t.name}</h3>
<p className="mt-1 font-mono text-xs text-zinc-600 dark:text-zinc-500">
{t.dimensions.length} dim.
</p>
<ul className="mt-2 space-y-0.5 font-mono text-xs text-zinc-600 dark:text-zinc-400">
{t.dimensions.slice(0, 5).map((d) => (
<li key={d.id}> {d.title}</li>
))}
{t.dimensions.length > 5 && (
<li className="text-zinc-600 dark:text-zinc-500">+{t.dimensions.length - 5}</li>
)}
</ul>
</div>
))}
</div>
</section>
</div>
);
return <AdminClient templates={templates} users={users} />;
}

View File

@@ -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 });
}

View File

@@ -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);
}

View File

@@ -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<string, unknown> = {};
if (candidateName != null) updateData.candidateName = candidateName;
if (candidateRole != null) updateData.candidateRole = candidateRole;
if (candidateTeam !== undefined) updateData.candidateTeam = candidateTeam || null;
if (evaluatorName != null) updateData.evaluatorName = evaluatorName;
if (evaluationDate != null) {
const d = new Date(evaluationDate);
if (!isNaN(d.getTime())) updateData.evaluationDate = d;
}
if (status != null) updateData.status = status;
if (findings != null) updateData.findings = findings;
if (recommendations != null) updateData.recommendations = recommendations;
if (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<string, unknown>,
});
}
if (dimensionScores && Array.isArray(dimensionScores)) {
for (const ds of dimensionScores) {
if (ds.dimensionId) {
await prisma.dimensionScore.upsert({
where: {
evaluationId_dimensionId: {
evaluationId: id,
dimensionId: ds.dimensionId,
},
},
update: {
score: ds.score,
justification: ds.justification,
examplesObserved: ds.examplesObserved,
confidence: ds.confidence,
candidateNotes: ds.candidateNotes,
},
create: {
evaluationId: id,
dimensionId: ds.dimensionId,
score: ds.score,
justification: ds.justification,
examplesObserved: ds.examplesObserved,
confidence: ds.confidence,
candidateNotes: ds.candidateNotes,
},
});
}
}
}
const updated = await prisma.evaluation.findUnique({
where: { id },
include: {
template: { include: { dimensions: { orderBy: { orderIndex: "asc" } } } },
dimensionScores: { include: { dimension: true } },
},
});
return NextResponse.json(updated);
} catch (e) {
console.error(e);
const msg = e instanceof Error ? e.message : "Failed to update evaluation";
return NextResponse.json({ error: msg }, { status: 500 });
}
}
export async function DELETE(
_req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const 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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

View File

@@ -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 });
}
}

View File

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

View File

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

View File

@@ -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 }
);
}
}

View File

@@ -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);
}

View File

@@ -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<EvalRow[]>([]);
const [loading, setLoading] = useState(true);
const [deleteTarget, setDeleteTarget] = useState<EvalRow | null>(null);
useEffect(() => {
fetch("/api/evaluations")
.then((r) => r.json())
.then(setEvaluations)
.catch(() => [])
.finally(() => setLoading(false));
}, []);
return (
<div>
<div className="mb-6 flex items-center justify-between">
<h1 className="font-mono text-lg font-medium text-zinc-800 dark:text-zinc-100">Évaluations</h1>
<Link
href="/evaluations/new"
className="rounded border border-cyan-500/50 bg-cyan-500/10 px-3 py-1.5 font-mono text-xs text-cyan-600 dark:text-cyan-400 hover:bg-cyan-500/20 transition-colors"
>
+ nouvelle
</Link>
</div>
{loading ? (
<div className="py-12 text-center font-mono text-xs text-zinc-600 dark:text-zinc-500">loading...</div>
) : evaluations.length === 0 ? (
<div className="py-12 text-center text-zinc-600 dark:text-zinc-500">
Aucune évaluation.{" "}
<Link href="/evaluations/new" className="text-cyan-600 dark:text-cyan-400 hover:underline">
Créer
</Link>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{evaluations.map((e) => {
const radarData = buildRadarData(e);
return (
<Link
key={e.id}
href={`/evaluations/${e.id}`}
className="group flex flex-col overflow-hidden rounded-lg border border-zinc-200 dark:border-zinc-600 bg-white dark:bg-zinc-800 shadow-sm dark:shadow-none hover:border-cyan-500/50 dark:hover:border-cyan-500/30 transition-colors"
>
<div className="flex flex-1 flex-col p-4">
<div className="mb-3 flex items-start justify-between gap-2">
<div className="min-w-0">
<h3 className="truncate font-medium text-zinc-800 dark:text-zinc-100">{e.candidateName}</h3>
<p className="truncate text-sm text-zinc-600 dark:text-zinc-400">
{e.candidateRole}
{e.candidateTeam && ` · ${e.candidateTeam}`}
</p>
</div>
<span
className={`shrink-0 font-mono text-xs px-1.5 py-0.5 rounded ${
e.status === "submitted" ? "bg-emerald-500/20 text-emerald-600 dark:text-emerald-400" : "bg-amber-500/20 text-amber-600 dark:text-amber-400"
}`}
>
{e.status === "submitted" ? "ok" : "draft"}
</span>
</div>
<div className="mb-3 flex flex-wrap gap-x-3 gap-y-0.5 font-mono text-xs text-zinc-500 dark:text-zinc-400">
<span>{e.evaluatorName}</span>
<span>{format(new Date(e.evaluationDate), "yyyy-MM-dd")}</span>
<span>{e.template?.name ?? ""}</span>
</div>
<div className="mt-auto min-h-[7rem]">
{radarData.length > 0 ? (
<RadarChart data={radarData} compact />
) : (
<div className="flex h-28 items-center justify-center rounded bg-zinc-50 dark:bg-zinc-700/30 font-mono text-xs text-zinc-400 dark:text-zinc-500">
pas de scores
</div>
)}
</div>
</div>
<div className="flex border-t border-zinc-200 dark:border-zinc-600 bg-zinc-50 dark:bg-zinc-700/30 px-4 py-2">
<span className="font-mono text-xs text-cyan-600 dark:text-cyan-400 group-hover:underline"> ouvrir</span>
<button
type="button"
onClick={(ev) => {
ev.preventDefault();
ev.stopPropagation();
setDeleteTarget(e);
}}
className="ml-auto font-mono text-xs text-red-500 hover:text-red-400"
title="Supprimer"
>
supprimer
</button>
</div>
</Link>
);
})}
</div>
)}
<ConfirmModal
isOpen={!!deleteTarget}
title="Supprimer l'évaluation"
message={
deleteTarget
? `Supprimer l'évaluation de ${deleteTarget.candidateName} ? Cette action est irréversible.`
: ""
}
confirmLabel="Supprimer"
cancelLabel="Annuler"
variant="danger"
onConfirm={async () => {
if (!deleteTarget) return;
const res = await fetch(`/api/evaluations/${deleteTarget.id}`, { method: "DELETE" });
if (res.ok) setEvaluations((prev) => prev.filter((x) => x.id !== deleteTarget.id));
else alert("Erreur lors de la suppression");
}}
onCancel={() => setDeleteTarget(null)}
/>
</div>
);
return <DashboardClient evaluations={evaluations} />;
}

View File

@@ -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<Evaluation | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [templates, setTemplates] = useState<{ id: string; name: string }[]>([]);
const [exportOpen, setExportOpen] = useState(false);
const [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<DimensionScore>) => {
if (!evaluation) return;
setEvaluation((e) => {
if (!e) return null;
const existing = e.dimensionScores.find((ds) => ds.dimensionId === dimensionId);
const dim = e.template?.dimensions?.find((d) => d.id === dimensionId);
const scores = existing
? e.dimensionScores.map((ds) =>
ds.dimensionId === dimensionId ? { ...ds, ...data } : ds
)
: [
...e.dimensionScores,
{
id: `temp-${dimensionId}`,
dimensionId,
score: (data as { score?: number }).score ?? null,
justification: (data as { justification?: string }).justification ?? null,
examplesObserved: (data as { examplesObserved?: string }).examplesObserved ?? null,
confidence: (data as { confidence?: string }).confidence ?? null,
candidateNotes: (data as { candidateNotes?: string }).candidateNotes ?? null,
dimension: dim ?? { id: dimensionId, slug: "", title: "", rubric: "" },
},
];
const next = { ...e, dimensionScores: scores };
if (data.score !== undefined) {
setTimeout(() => handleSave(next, { skipRefresh: true }), 0);
}
return next;
});
};
const handleSave = async (evalOverride?: Evaluation | null, options?: { skipRefresh?: boolean }) => {
const toSave = evalOverride ?? evaluation;
if (!toSave) return;
setSaving(true);
try {
const res = await fetch(`/api/evaluations/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
candidateName: toSave.candidateName,
candidateRole: toSave.candidateRole,
candidateTeam: toSave.candidateTeam ?? null,
evaluatorName: toSave.evaluatorName,
evaluationDate: typeof toSave.evaluationDate === "string" ? toSave.evaluationDate : new Date(toSave.evaluationDate).toISOString(),
status: toSave.status,
findings: toSave.findings,
recommendations: toSave.recommendations,
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 <div className="py-12 text-center font-mono text-xs text-zinc-600 dark:text-zinc-500">loading...</div>;
}
if (!evaluation) {
return (
<div className="py-12 text-center font-mono text-xs text-zinc-600 dark:text-zinc-500">
@@ -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 (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<h1 className="font-mono text-base font-medium text-zinc-800 dark:text-zinc-100">
{evaluation.candidateName}
{evaluation.candidateTeam && (
<span className="text-zinc-500"> ({evaluation.candidateTeam})</span>
)}
<span className="text-zinc-500"> / </span> {evaluation.candidateRole}
</h1>
<div className="flex gap-2">
<button
onClick={() => handleSave()}
disabled={saving}
className="rounded border border-zinc-300 dark:border-zinc-600 bg-zinc-100 dark:bg-zinc-700 px-3 py-1.5 font-mono text-xs text-zinc-700 dark:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-zinc-700 disabled:opacity-50"
>
{saving ? "..." : "save"}
</button>
<button
onClick={() => {
const next = !(evaluation.isPublic ?? false);
setEvaluation((e) => (e ? { ...e, isPublic: next } : null));
handleSave(evaluation ? { ...evaluation, isPublic: next } : null);
}}
className={`rounded border px-3 py-1.5 font-mono text-xs ${
evaluation.isPublic
? "border-emerald-500/50 bg-emerald-500/20 text-emerald-600 dark:text-emerald-400"
: "border-zinc-300 dark:border-zinc-600 bg-zinc-100 dark:bg-zinc-700 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-200 dark:hover:bg-zinc-600"
}`}
>
{evaluation.isPublic ? "publique" : "rendre publique"}
</button>
<button
onClick={() => setShareOpen(true)}
className="rounded border border-cyan-500/50 bg-cyan-500/10 px-3 py-1.5 font-mono text-xs text-cyan-600 dark:text-cyan-400 hover:bg-cyan-500/20"
>
partager
</button>
<button
onClick={() => setExportOpen(true)}
className="rounded border border-cyan-500/50 bg-cyan-500/10 px-3 py-1.5 font-mono text-xs text-cyan-600 dark:text-cyan-400 hover:bg-cyan-500/20"
>
export
</button>
</div>
</div>
{showAllFivesWarning && (
<div className="rounded border border-amber-500/30 bg-amber-500/10 p-3 font-mono text-xs text-amber-600 dark:text-amber-400">
Tous les scores = 5 sans justification
</div>
)}
<section className="relative overflow-hidden rounded-xl border border-zinc-200 dark:border-zinc-600 bg-gradient-to-br from-zinc-50 to-white dark:from-zinc-800/80 dark:to-zinc-800 p-5 shadow-sm dark:shadow-none">
<div className="absolute left-0 top-0 h-full w-1 bg-gradient-to-b from-cyan-500/60 to-cyan-400/40" aria-hidden />
<h2 className="mb-4 font-mono text-xs font-medium uppercase tracking-wider text-zinc-500 dark:text-zinc-400">
Session
</h2>
<CandidateForm
candidateName={evaluation.candidateName}
candidateRole={evaluation.candidateRole}
candidateTeam={evaluation.candidateTeam ?? ""}
evaluatorName={evaluation.evaluatorName}
evaluationDate={evaluation.evaluationDate.split("T")[0]}
templateId={evaluation.templateId}
templates={templates}
onChange={handleFormChange}
templateDisabled
/>
</section>
<section>
<div className="mb-3 flex items-center justify-between gap-2">
<h2 className="font-mono text-xs text-zinc-600 dark:text-zinc-500">Dimensions</h2>
<button
type="button"
onClick={() => setCollapseAllTrigger((c) => c + 1)}
className="font-mono text-xs text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300"
>
tout fermer
</button>
</div>
<nav className="mb-4 flex flex-wrap gap-1.5">
{dimensions.map((dim, i) => {
const ds = scoreMap.get(dim.id);
const hasScore = ds?.score != null;
return (
<a
key={dim.id}
href={`#dim-${dim.id}`}
className={`rounded px-2 py-0.5 font-mono text-xs transition-colors ${
hasScore
? "bg-cyan-500/20 text-cyan-600 dark:text-cyan-400 hover:bg-cyan-500/30"
: "text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800 hover:text-zinc-700 dark:hover:text-zinc-300"
}`}
>
{i + 1}. {dim.title}
</a>
);
})}
</nav>
<div className="space-y-2">
{dimensions.map((dim, i) => (
<div key={dim.id} id={`dim-${dim.id}`} className="scroll-mt-24">
<DimensionCard
dimension={dim}
index={i}
evaluationId={id}
score={scoreMap.get(dim.id) ?? null}
onScoreChange={handleScoreChange}
collapseAllTrigger={collapseAllTrigger}
/>
</div>
))}
</div>
</section>
<section className="rounded-lg border border-zinc-200 dark:border-zinc-600 bg-white dark:bg-zinc-800 p-4 shadow-sm dark:shadow-none">
<h2 className="mb-3 font-mono text-xs text-zinc-600 dark:text-zinc-500">Synthèse</h2>
<p className="mb-4 font-mono text-sm text-zinc-700 dark:text-zinc-300">
Moyenne <span className="text-cyan-600 dark:text-cyan-400">{avgScore.toFixed(1)}/5</span>
</p>
<RadarChart data={radarData} />
<div className="mt-4 grid gap-4 sm:grid-cols-2">
<div>
<label className="mb-1 block text-xs text-zinc-600 dark:text-zinc-500">Synthèse</label>
<textarea
value={evaluation.findings ?? ""}
onChange={(e) => setEvaluation((ev) => (ev ? { ...ev, findings: e.target.value } : null))}
rows={3}
className="w-full rounded border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-700/80 px-2.5 py-1.5 text-sm text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 dark:placeholder-zinc-500 focus:border-cyan-500"
/>
</div>
<div>
<label className="mb-1 block text-xs text-zinc-600 dark:text-zinc-500">Recommandations</label>
<textarea
value={evaluation.recommendations ?? ""}
onChange={(e) =>
setEvaluation((ev) => (ev ? { ...ev, recommendations: e.target.value } : null))
}
rows={3}
className="w-full rounded border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-700/80 px-2.5 py-1.5 text-sm text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 dark:placeholder-zinc-500 focus:border-cyan-500"
/>
</div>
</div>
<button
type="button"
onClick={handleGenerateFindings}
className="mt-2 font-mono text-xs text-cyan-600 dark:text-cyan-400 hover:text-cyan-500 dark:hover:text-cyan-300"
>
auto-générer
</button>
</section>
<div className="flex flex-wrap gap-3">
<button
onClick={() => {
const updated = evaluation ? { ...evaluation, status: "submitted" } : null;
setEvaluation(updated);
if (updated) handleSave(updated);
}}
className="rounded border border-emerald-500/50 bg-emerald-500/20 px-3 py-1.5 font-mono text-xs text-emerald-600 dark:text-emerald-400 hover:bg-emerald-500/30"
>
soumettre
</button>
<button onClick={() => router.push("/dashboard")} className="rounded border border-zinc-300 dark:border-zinc-600 px-3 py-1.5 font-mono text-xs text-zinc-600 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700">
dashboard
</button>
<button
type="button"
onClick={() => setDeleteConfirmOpen(true)}
className="rounded border border-red-500/30 px-3 py-1.5 font-mono text-xs text-red-600 dark:text-red-400 hover:bg-red-500/10"
>
supprimer
</button>
</div>
<ExportModal
isOpen={exportOpen}
onClose={() => setExportOpen(false)}
evaluationId={id}
/>
<ShareModal
isOpen={shareOpen}
onClose={() => setShareOpen(false)}
evaluationId={id}
evaluatorId={evaluation.evaluatorId}
users={users}
sharedWith={evaluation.sharedWith ?? []}
onUpdate={fetchEval}
/>
<ConfirmModal
isOpen={deleteConfirmOpen}
title="Supprimer l'évaluation"
message={`Supprimer l'évaluation de ${evaluation.candidateName} ? Cette action est irréversible.`}
confirmLabel="Supprimer"
cancelLabel="Annuler"
variant="danger"
onConfirm={async () => {
const res = await fetch(`/api/evaluations/${id}`, { method: "DELETE" });
if (res.ok) router.push("/dashboard");
else alert("Erreur lors de la suppression");
}}
onCancel={() => setDeleteConfirmOpen(false)}
/>
</div>
<EvaluationEditor
id={id}
initialEvaluation={evaluation as unknown as Parameters<typeof EvaluationEditor>[0]["initialEvaluation"]}
templates={templatesForEditor}
users={users}
/>
);
}

View File

@@ -1,102 +1,18 @@
"use client";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { getTemplates } from "@/lib/server-data";
import { NewEvaluationForm } from "@/components/NewEvaluationForm";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import { useSession } from "next-auth/react";
import { CandidateForm } from "@/components/CandidateForm";
export default async function NewEvaluationPage() {
const [session, templates] = await Promise.all([auth(), getTemplates()]);
if (!session?.user) redirect("/auth/login");
export default function NewEvaluationPage() {
const router = useRouter();
const { data: session } = useSession();
const [templates, setTemplates] = useState<{ id: string; name: string }[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [form, setForm] = useState({
candidateName: "",
candidateRole: "",
candidateTeam: "",
evaluatorName: "",
evaluationDate: new Date().toISOString().split("T")[0],
templateId: "",
});
useEffect(() => {
if (session?.user) {
const display = session.user.name || session.user.email || "";
setForm((f) => ({ ...f, evaluatorName: display }));
}
}, [session?.user]);
useEffect(() => {
fetch("/api/templates")
.then((r) => r.json())
.then((data) => {
setTemplates(data);
if (data.length > 0 && !form.templateId) {
setForm((f) => ({ ...f, templateId: data[0].id }));
}
})
.finally(() => setLoading(false));
// eslint-disable-next-line react-hooks/exhaustive-deps -- run once on mount to fetch templates and set initial templateId
}, []);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!form.templateId) return;
setSaving(true);
try {
const res = await fetch("/api/evaluations", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
...form,
evaluationDate: new Date(form.evaluationDate).toISOString(),
}),
});
const data = await res.json();
if (res.ok) {
router.push(`/evaluations/${data.id}`);
} else {
alert(data.error ?? "Erreur");
}
} finally {
setSaving(false);
}
};
if (loading) {
return <div className="py-12 text-center font-mono text-xs text-zinc-600 dark:text-zinc-500">loading templates...</div>;
}
const initialEvaluatorName = session.user.name || session.user.email || "";
return (
<div>
<h1 className="mb-6 font-mono text-lg font-medium text-zinc-800 dark:text-zinc-200">Nouvelle évaluation</h1>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="rounded-lg border border-zinc-200 dark:border-zinc-600 bg-white dark:bg-zinc-800 p-4 shadow-sm dark:shadow-none">
<h2 className="mb-3 font-mono text-xs text-zinc-600 dark:text-zinc-500">Session</h2>
<CandidateForm
{...form}
templates={templates}
onChange={(field, value) => setForm((f) => ({ ...f, [field]: value }))}
/>
</div>
<div className="flex gap-2">
<button
type="submit"
disabled={saving || !form.templateId}
className="rounded border border-cyan-500/50 bg-cyan-500/20 px-4 py-2 font-mono text-sm text-cyan-600 dark:text-cyan-400 hover:bg-cyan-500/30 disabled:opacity-50"
>
{saving ? "..." : "créer →"}
</button>
<button
type="button"
onClick={() => router.back()}
className="rounded border border-zinc-300 dark:border-zinc-700 px-4 py-2 font-mono text-sm text-zinc-600 dark:text-zinc-400 hover:bg-zinc-100 dark:hover:bg-zinc-800"
>
annuler
</button>
</div>
</form>
</div>
<NewEvaluationForm
templates={templates.map((t) => ({ id: t.id, name: t.name }))}
initialEvaluatorName={initialEvaluatorName}
/>
);
}

View File

@@ -1,129 +1,10 @@
"use client";
import { redirect } from "next/navigation";
import { auth } from "@/auth";
import { SettingsPasswordForm } from "@/components/SettingsPasswordForm";
import { useState } from "react";
import Link from "next/link";
export default async function SettingsPage() {
const session = await auth();
if (!session?.user) redirect("/auth/login");
export default function SettingsPage() {
const [currentPassword, setCurrentPassword] = useState("");
const [newPassword, setNewPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState("");
const [success, setSuccess] = useState(false);
const [loading, setLoading] = useState(false);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError("");
setSuccess(false);
if (newPassword !== confirmPassword) {
setError("Les deux nouveaux mots de passe ne correspondent pas");
return;
}
if (newPassword.length < 8) {
setError("Le mot de passe doit faire au moins 8 caractères");
return;
}
setLoading(true);
try {
const res = await fetch("/api/users/me/password", {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
currentPassword,
newPassword,
}),
});
const data = await res.json();
if (!res.ok) {
setError(data.error ?? "Erreur");
return;
}
setSuccess(true);
setCurrentPassword("");
setNewPassword("");
setConfirmPassword("");
} catch {
setError("Erreur de connexion");
} finally {
setLoading(false);
}
}
return (
<div className="mx-auto max-w-md">
<h1 className="mb-6 font-mono text-lg font-medium text-zinc-800 dark:text-zinc-100">
Paramètres
</h1>
<section className="rounded-lg border border-zinc-200 dark:border-zinc-600 bg-white dark:bg-zinc-800/50 p-4">
<h2 className="mb-4 font-mono text-sm font-medium text-zinc-700 dark:text-zinc-300">
Changer mon mot de passe
</h2>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="mb-1 block font-mono text-xs text-zinc-600 dark:text-zinc-400">
Mot de passe actuel
</label>
<input
type="password"
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
required
autoComplete="current-password"
className="w-full rounded border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-800 px-3 py-2 text-sm text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500/30"
/>
</div>
<div>
<label className="mb-1 block font-mono text-xs text-zinc-600 dark:text-zinc-400">
Nouveau mot de passe
</label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
required
minLength={8}
autoComplete="new-password"
className="w-full rounded border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-800 px-3 py-2 text-sm text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500/30"
/>
</div>
<div>
<label className="mb-1 block font-mono text-xs text-zinc-600 dark:text-zinc-400">
Confirmer le nouveau mot de passe
</label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
required
minLength={8}
autoComplete="new-password"
className="w-full rounded border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-800 px-3 py-2 text-sm text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 focus:border-cyan-500 focus:ring-1 focus:ring-cyan-500/30"
/>
</div>
{error && (
<p className="font-mono text-xs text-red-500">{error}</p>
)}
{success && (
<p className="font-mono text-xs text-emerald-600 dark:text-emerald-400">
Mot de passe modifié.
</p>
)}
<button
type="submit"
disabled={loading}
className="rounded border border-cyan-500/50 bg-cyan-500/10 px-3 py-2 font-mono text-sm text-cyan-600 dark:text-cyan-400 hover:bg-cyan-500/20 disabled:opacity-50"
>
{loading ? "..." : "Modifier le mot de passe"}
</button>
</form>
</section>
<p className="mt-6 font-mono text-xs text-zinc-500 dark:text-zinc-400">
<Link href="/dashboard" className="text-cyan-600 dark:text-cyan-400 hover:underline">
Retour au dashboard
</Link>
</p>
</div>
);
return <SettingsPasswordForm />;
}

View File

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

View File

@@ -0,0 +1,161 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { deleteEvaluation } from "@/actions/evaluations";
import { format } from "date-fns";
import { ConfirmModal } from "@/components/ConfirmModal";
import { RadarChart } from "@/components/RadarChart";
interface Dimension {
id: string;
title: string;
}
interface EvalRow {
id: string;
candidateName: string;
candidateRole: string;
candidateTeam?: string | null;
evaluatorName: string;
evaluationDate: string;
template?: { name: string; dimensions?: Dimension[] };
status: string;
dimensionScores?: { dimensionId: string; score: number | null; dimension?: { title: string } }[];
}
function buildRadarData(e: EvalRow) {
const dimensions = e.template?.dimensions ?? [];
const scoreMap = new Map(
(e.dimensionScores ?? []).map((ds) => [ds.dimensionId, ds])
);
return dimensions
.filter((dim) => !(dim.title ?? "").startsWith("[Optionnel]"))
.map((dim) => {
const ds = scoreMap.get(dim.id);
const score = ds?.score;
if (score == null) return null;
const s = Number(score);
if (Number.isNaN(s) || s < 0 || s > 5) return null;
const title = dim.title ?? "";
return {
dimension: title.length > 12 ? title.slice(0, 12) + "…" : title,
score: s,
fullMark: 5,
};
})
.filter((d): d is { dimension: string; score: number; fullMark: number } => d != null);
}
interface DashboardClientProps {
evaluations: EvalRow[];
}
export function DashboardClient({ evaluations }: DashboardClientProps) {
const [list, setList] = useState(evaluations);
const [deleteTarget, setDeleteTarget] = useState<EvalRow | null>(null);
return (
<div>
<div className="mb-6 flex items-center justify-between">
<h1 className="font-mono text-lg font-medium text-zinc-800 dark:text-zinc-100">Évaluations</h1>
<Link
href="/evaluations/new"
className="rounded border border-cyan-500/50 bg-cyan-500/10 px-3 py-1.5 font-mono text-xs text-cyan-600 dark:text-cyan-400 hover:bg-cyan-500/20 transition-colors"
>
+ nouvelle
</Link>
</div>
{list.length === 0 ? (
<div className="py-12 text-center text-zinc-600 dark:text-zinc-500">
Aucune évaluation.{" "}
<Link href="/evaluations/new" className="text-cyan-600 dark:text-cyan-400 hover:underline">
Créer
</Link>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{list.map((e) => {
const radarData = buildRadarData(e);
return (
<Link
key={e.id}
href={`/evaluations/${e.id}`}
className="group flex flex-col overflow-hidden rounded-lg border border-zinc-200 dark:border-zinc-600 bg-white dark:bg-zinc-800 shadow-sm dark:shadow-none hover:border-cyan-500/50 dark:hover:border-cyan-500/30 transition-colors"
>
<div className="flex flex-1 flex-col p-4">
<div className="mb-3 flex items-start justify-between gap-2">
<div className="min-w-0">
<h3 className="truncate font-medium text-zinc-800 dark:text-zinc-100">{e.candidateName}</h3>
<p className="truncate text-sm text-zinc-600 dark:text-zinc-400">
{e.candidateRole}
{e.candidateTeam && ` · ${e.candidateTeam}`}
</p>
</div>
<span
className={`shrink-0 font-mono text-xs px-1.5 py-0.5 rounded ${
e.status === "submitted" ? "bg-emerald-500/20 text-emerald-600 dark:text-emerald-400" : "bg-amber-500/20 text-amber-600 dark:text-amber-400"
}`}
>
{e.status === "submitted" ? "ok" : "draft"}
</span>
</div>
<div className="mb-3 flex flex-wrap gap-x-3 gap-y-0.5 font-mono text-xs text-zinc-500 dark:text-zinc-400">
<span>{e.evaluatorName}</span>
<span>{format(new Date(e.evaluationDate), "yyyy-MM-dd")}</span>
<span>{e.template?.name ?? ""}</span>
</div>
<div className="mt-auto min-h-[7rem]">
{radarData.length > 0 ? (
<RadarChart data={radarData} compact />
) : (
<div className="flex h-28 items-center justify-center rounded bg-zinc-50 dark:bg-zinc-700/30 font-mono text-xs text-zinc-400 dark:text-zinc-500">
pas de scores
</div>
)}
</div>
</div>
<div className="flex border-t border-zinc-200 dark:border-zinc-600 bg-zinc-50 dark:bg-zinc-700/30 px-4 py-2">
<span className="font-mono text-xs text-cyan-600 dark:text-cyan-400 group-hover:underline"> ouvrir</span>
<button
type="button"
onClick={(ev) => {
ev.preventDefault();
ev.stopPropagation();
setDeleteTarget(e);
}}
className="ml-auto font-mono text-xs text-red-500 hover:text-red-400"
title="Supprimer"
>
supprimer
</button>
</div>
</Link>
);
})}
</div>
)}
<ConfirmModal
isOpen={!!deleteTarget}
title="Supprimer l'évaluation"
message={
deleteTarget
? `Supprimer l'évaluation de ${deleteTarget.candidateName} ? Cette action est irréversible.`
: ""
}
confirmLabel="Supprimer"
cancelLabel="Annuler"
variant="danger"
onConfirm={async () => {
if (!deleteTarget) return;
const result = await deleteEvaluation(deleteTarget.id);
if (result.success) setList((prev) => prev.filter((x) => x.id !== deleteTarget.id));
else alert(result.error);
}}
onCancel={() => setDeleteTarget(null)}
/>
</div>
);
}

View File

@@ -0,0 +1,407 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { updateEvaluation, deleteEvaluation, fetchEvaluation } from "@/actions/evaluations";
import { CandidateForm } from "@/components/CandidateForm";
import { DimensionCard } from "@/components/DimensionCard";
import { RadarChart } from "@/components/RadarChart";
import { ExportModal } from "@/components/ExportModal";
import { ShareModal } from "@/components/ShareModal";
import { ConfirmModal } from "@/components/ConfirmModal";
import { generateFindings, generateRecommendations, computeAverageScore } from "@/lib/export-utils";
interface Dimension {
id: string;
slug: string;
title: string;
rubric: string;
suggestedQuestions?: string | null;
}
interface DimensionScore {
id: string;
dimensionId: string;
score: number | null;
justification: string | null;
examplesObserved: string | null;
confidence: string | null;
candidateNotes: string | null;
dimension: Dimension;
}
interface Evaluation {
id: string;
candidateName: string;
candidateRole: string;
candidateTeam?: string | null;
evaluatorName: string;
evaluatorId?: string | null;
evaluationDate: string;
templateId: string;
template: { id: string; name: string; dimensions: Dimension[] };
status: string;
findings: string | null;
recommendations: string | null;
dimensionScores: DimensionScore[];
sharedWith?: { id: string; user: { id: string; email: string; name: string | null } }[];
isPublic?: boolean;
}
interface EvaluationEditorProps {
id: string;
initialEvaluation: Evaluation;
templates: { id: string; name: string; dimensions?: { id: string; suggestedQuestions?: string | null }[] }[];
users: { id: string; email: string; name: string | null }[];
}
export function EvaluationEditor({ id, initialEvaluation, templates, users }: EvaluationEditorProps) {
const router = useRouter();
const [evaluation, setEvaluation] = useState<Evaluation>(initialEvaluation);
const [saving, setSaving] = useState(false);
const [exportOpen, setExportOpen] = useState(false);
const [shareOpen, setShareOpen] = useState(false);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false);
const [collapseAllTrigger, setCollapseAllTrigger] = useState(0);
const fetchEval = useCallback(async () => {
const result = await fetchEvaluation(id);
if (result.success && result.data) {
const d = result.data;
setEvaluation({ ...d, dimensionScores: d.dimensionScores ?? [] } as Evaluation);
}
}, [id]);
// Draft backup to localStorage (debounced)
useEffect(() => {
if (!evaluation || !id) return;
const t = setTimeout(() => {
try {
localStorage.setItem(
`eval-draft-${id}`,
JSON.stringify({ ...evaluation, evaluationDate: evaluation.evaluationDate })
);
} catch {
/* ignore */
}
}, 2000);
return () => clearTimeout(t);
}, [evaluation, id]);
const handleFormChange = (field: string, value: string) => {
setEvaluation((e) => (e ? { ...e, [field]: value } : null!));
};
const handleScoreChange = (dimensionId: string, data: Partial<DimensionScore>) => {
setEvaluation((e) => {
if (!e) return null!;
const existing = e.dimensionScores.find((ds) => ds.dimensionId === dimensionId);
const dim = e.template?.dimensions?.find((d) => d.id === dimensionId);
const scores = existing
? e.dimensionScores.map((ds) =>
ds.dimensionId === dimensionId ? { ...ds, ...data } : ds
)
: [
...e.dimensionScores,
{
id: `temp-${dimensionId}`,
dimensionId,
score: (data as { score?: number }).score ?? null,
justification: (data as { justification?: string }).justification ?? null,
examplesObserved: (data as { examplesObserved?: string }).examplesObserved ?? null,
confidence: (data as { confidence?: string }).confidence ?? null,
candidateNotes: (data as { candidateNotes?: string }).candidateNotes ?? null,
dimension: dim ?? { id: dimensionId, slug: "", title: "", rubric: "" },
},
];
const next = { ...e, dimensionScores: scores };
if (data.score !== undefined) {
setTimeout(() => handleSave(next, { skipRefresh: true }), 0);
}
return next;
});
};
const handleSave = async (evalOverride?: Evaluation | null, options?: { skipRefresh?: boolean }) => {
const toSave = evalOverride ?? evaluation;
if (!toSave) return;
setSaving(true);
try {
const result = await updateEvaluation(id, {
candidateName: toSave.candidateName,
candidateRole: toSave.candidateRole,
candidateTeam: toSave.candidateTeam ?? null,
evaluatorName: toSave.evaluatorName,
evaluationDate: typeof toSave.evaluationDate === "string" ? toSave.evaluationDate : new Date(toSave.evaluationDate).toISOString(),
status: toSave.status,
findings: toSave.findings,
recommendations: toSave.recommendations,
isPublic: toSave.isPublic ?? false,
dimensionScores: (toSave.dimensionScores ?? []).map((ds) => ({
dimensionId: ds.dimensionId,
evaluationId: id,
score: ds.score,
justification: ds.justification,
examplesObserved: ds.examplesObserved,
confidence: ds.confidence,
candidateNotes: ds.candidateNotes,
})),
});
if (result.success) {
if (!options?.skipRefresh) fetchEval();
} else {
alert(result.error);
}
} catch (err) {
console.error("Save error:", err);
alert("Erreur lors de la sauvegarde");
} finally {
setSaving(false);
}
};
const handleGenerateFindings = () => {
const findings = generateFindings(evaluation.dimensionScores ?? []);
const recommendations = generateRecommendations(evaluation.dimensionScores ?? []);
setEvaluation((e) => (e ? { ...e, findings, recommendations } : null!));
};
const allFives = evaluation?.dimensionScores?.every(
(ds) => ds.score === 5 && (!ds.justification || ds.justification.trim() === "")
);
const showAllFivesWarning = allFives && evaluation?.status === "submitted";
const dimensions = evaluation.template?.dimensions ?? [];
const dimensionScores = evaluation.dimensionScores ?? [];
const scoreMap = new Map(dimensionScores.map((ds) => [ds.dimensionId, ds]));
const radarData = dimensions
.filter((dim) => !(dim.title ?? "").startsWith("[Optionnel]"))
.map((dim) => {
const ds = scoreMap.get(dim.id);
const score = ds?.score;
if (score == null) return null;
const title = dim.title ?? "";
const s = Number(score);
if (Number.isNaN(s) || s < 0 || s > 5) return null;
return {
dimension: title.length > 12 ? title.slice(0, 12) + "…" : title,
score: s,
fullMark: 5,
};
})
.filter((d): d is { dimension: string; score: number; fullMark: number } => d != null);
const avgScore = computeAverageScore(dimensionScores);
const templatesForForm = templates.map((t) => ({ id: t.id, name: t.name }));
return (
<div className="space-y-6">
<div className="flex flex-wrap items-center justify-between gap-4">
<h1 className="font-mono text-base font-medium text-zinc-800 dark:text-zinc-100">
{evaluation.candidateName}
{evaluation.candidateTeam && (
<span className="text-zinc-500"> ({evaluation.candidateTeam})</span>
)}
<span className="text-zinc-500"> / </span> {evaluation.candidateRole}
</h1>
<div className="flex gap-2">
<button
onClick={() => handleSave()}
disabled={saving}
className="rounded border border-zinc-300 dark:border-zinc-600 bg-zinc-100 dark:bg-zinc-700 px-3 py-1.5 font-mono text-xs text-zinc-700 dark:text-zinc-300 hover:bg-zinc-200 dark:hover:bg-zinc-700 disabled:opacity-50"
>
{saving ? "..." : "save"}
</button>
<button
onClick={() => {
const next = !(evaluation.isPublic ?? false);
setEvaluation((e) => (e ? { ...e, isPublic: next } : null!));
handleSave(evaluation ? { ...evaluation, isPublic: next } : null);
}}
className={`rounded border px-3 py-1.5 font-mono text-xs ${
evaluation.isPublic
? "border-emerald-500/50 bg-emerald-500/20 text-emerald-600 dark:text-emerald-400"
: "border-zinc-300 dark:border-zinc-600 bg-zinc-100 dark:bg-zinc-700 text-zinc-600 dark:text-zinc-400 hover:bg-zinc-200 dark:hover:bg-zinc-600"
}`}
>
{evaluation.isPublic ? "publique" : "rendre publique"}
</button>
<button
onClick={() => setShareOpen(true)}
className="rounded border border-cyan-500/50 bg-cyan-500/10 px-3 py-1.5 font-mono text-xs text-cyan-600 dark:text-cyan-400 hover:bg-cyan-500/20"
>
partager
</button>
<button
onClick={() => setExportOpen(true)}
className="rounded border border-cyan-500/50 bg-cyan-500/10 px-3 py-1.5 font-mono text-xs text-cyan-600 dark:text-cyan-400 hover:bg-cyan-500/20"
>
export
</button>
</div>
</div>
{showAllFivesWarning && (
<div className="rounded border border-amber-500/30 bg-amber-500/10 p-3 font-mono text-xs text-amber-600 dark:text-amber-400">
Tous les scores = 5 sans justification
</div>
)}
<section className="relative overflow-hidden rounded-xl border border-zinc-200 dark:border-zinc-600 bg-gradient-to-br from-zinc-50 to-white dark:from-zinc-800/80 dark:to-zinc-800 p-5 shadow-sm dark:shadow-none">
<div className="absolute left-0 top-0 h-full w-1 bg-gradient-to-b from-cyan-500/60 to-cyan-400/40" aria-hidden />
<h2 className="mb-4 font-mono text-xs font-medium uppercase tracking-wider text-zinc-500 dark:text-zinc-400">
Session
</h2>
<CandidateForm
candidateName={evaluation.candidateName}
candidateRole={evaluation.candidateRole}
candidateTeam={evaluation.candidateTeam ?? ""}
evaluatorName={evaluation.evaluatorName}
evaluationDate={evaluation.evaluationDate.split("T")[0]}
templateId={evaluation.templateId}
templates={templatesForForm}
onChange={handleFormChange}
templateDisabled
/>
</section>
<section>
<div className="mb-3 flex items-center justify-between gap-2">
<h2 className="font-mono text-xs text-zinc-600 dark:text-zinc-500">Dimensions</h2>
<button
type="button"
onClick={() => setCollapseAllTrigger((c) => c + 1)}
className="font-mono text-xs text-zinc-500 hover:text-zinc-700 dark:hover:text-zinc-300"
>
tout fermer
</button>
</div>
<nav className="mb-4 flex flex-wrap gap-1.5">
{dimensions.map((dim, i) => {
const ds = scoreMap.get(dim.id);
const hasScore = ds?.score != null;
return (
<a
key={dim.id}
href={`#dim-${dim.id}`}
className={`rounded px-2 py-0.5 font-mono text-xs transition-colors ${
hasScore
? "bg-cyan-500/20 text-cyan-600 dark:text-cyan-400 hover:bg-cyan-500/30"
: "text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800 hover:text-zinc-700 dark:hover:text-zinc-300"
}`}
>
{i + 1}. {dim.title}
</a>
);
})}
</nav>
<div className="space-y-2">
{dimensions.map((dim, i) => (
<div key={dim.id} id={`dim-${dim.id}`} className="scroll-mt-24">
<DimensionCard
dimension={dim}
index={i}
evaluationId={id}
score={scoreMap.get(dim.id) ?? null}
onScoreChange={handleScoreChange}
collapseAllTrigger={collapseAllTrigger}
/>
</div>
))}
</div>
</section>
<section className="rounded-lg border border-zinc-200 dark:border-zinc-600 bg-white dark:bg-zinc-800 p-4 shadow-sm dark:shadow-none">
<h2 className="mb-3 font-mono text-xs text-zinc-600 dark:text-zinc-500">Synthèse</h2>
<p className="mb-4 font-mono text-sm text-zinc-700 dark:text-zinc-300">
Moyenne <span className="text-cyan-600 dark:text-cyan-400">{avgScore.toFixed(1)}/5</span>
</p>
<RadarChart data={radarData} />
<div className="mt-4 grid gap-4 sm:grid-cols-2">
<div>
<label className="mb-1 block text-xs text-zinc-600 dark:text-zinc-500">Synthèse</label>
<textarea
value={evaluation.findings ?? ""}
onChange={(e) => setEvaluation((ev) => (ev ? { ...ev, findings: e.target.value } : null!))}
rows={3}
className="w-full rounded border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-700/80 px-2.5 py-1.5 text-sm text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 dark:placeholder-zinc-500 focus:border-cyan-500"
/>
</div>
<div>
<label className="mb-1 block text-xs text-zinc-600 dark:text-zinc-500">Recommandations</label>
<textarea
value={evaluation.recommendations ?? ""}
onChange={(e) =>
setEvaluation((ev) => (ev ? { ...ev, recommendations: e.target.value } : null!))
}
rows={3}
className="w-full rounded border border-zinc-300 dark:border-zinc-600 bg-white dark:bg-zinc-700/80 px-2.5 py-1.5 text-sm text-zinc-900 dark:text-zinc-100 placeholder-zinc-400 dark:placeholder-zinc-500 focus:border-cyan-500"
/>
</div>
</div>
<button
type="button"
onClick={handleGenerateFindings}
className="mt-2 font-mono text-xs text-cyan-600 dark:text-cyan-400 hover:text-cyan-500 dark:hover:text-cyan-300"
>
auto-générer
</button>
</section>
<div className="flex flex-wrap gap-3">
<button
onClick={() => {
const updated = { ...evaluation, status: "submitted" };
setEvaluation(updated);
handleSave(updated);
}}
className="rounded border border-emerald-500/50 bg-emerald-500/20 px-3 py-1.5 font-mono text-xs text-emerald-600 dark:text-emerald-400 hover:bg-emerald-500/30"
>
soumettre
</button>
<button onClick={() => router.push("/dashboard")} className="rounded border border-zinc-300 dark:border-zinc-600 px-3 py-1.5 font-mono text-xs text-zinc-600 dark:text-zinc-300 hover:bg-zinc-100 dark:hover:bg-zinc-700">
dashboard
</button>
<button
type="button"
onClick={() => setDeleteConfirmOpen(true)}
className="rounded border border-red-500/30 px-3 py-1.5 font-mono text-xs text-red-600 dark:text-red-400 hover:bg-red-500/10"
>
supprimer
</button>
</div>
<ExportModal
isOpen={exportOpen}
onClose={() => setExportOpen(false)}
evaluationId={id}
/>
<ShareModal
isOpen={shareOpen}
onClose={() => setShareOpen(false)}
evaluationId={id}
evaluatorId={evaluation.evaluatorId}
users={users}
sharedWith={evaluation.sharedWith ?? []}
onUpdate={fetchEval}
/>
<ConfirmModal
isOpen={deleteConfirmOpen}
title="Supprimer l'évaluation"
message={`Supprimer l'évaluation de ${evaluation.candidateName} ? Cette action est irréversible.`}
confirmLabel="Supprimer"
cancelLabel="Annuler"
variant="danger"
onConfirm={async () => {
const result = await deleteEvaluation(id);
if (result.success) router.push("/dashboard");
else alert(result.error);
}}
onCancel={() => setDeleteConfirmOpen(false)}
/>
</div>
);
}

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { addShare, removeShare } from "@/actions/share";
interface User {
id: string;
@@ -45,17 +46,12 @@ export function ShareModal({
if (!shareUserId) return;
setLoading(true);
try {
const res = await fetch(`/api/evaluations/${evaluationId}/share`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ userId: shareUserId }),
});
const data = await res.json().catch(() => ({}));
if (res.ok) {
const result = await addShare(evaluationId, shareUserId);
if (result.success) {
setShareUserId("");
onUpdate();
} else {
alert(data.error ?? "Erreur");
alert(result.error);
}
} finally {
setLoading(false);
@@ -63,10 +59,8 @@ export function ShareModal({
}
async function handleRemove(userId: string) {
const res = await fetch(`/api/evaluations/${evaluationId}/share/${userId}`, {
method: "DELETE",
});
if (res.ok) onUpdate();
const result = await removeShare(evaluationId, userId);
if (result.success) onUpdate();
}
return (

View File

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

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

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