Compare commits

...

18 Commits

Author SHA1 Message Date
Julien Froidefond
7852dad403 chore: format spacing in docker-compose.yml for improved readability
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m8s
2025-12-17 12:55:57 +01:00
Julien Froidefond
0742aae92f chore: update docker-compose.yml to allow dynamic initialization SQL file path
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 5s
2025-12-17 12:52:53 +01:00
Julien Froidefond
87a24d7ebf chore: optimize dependency installation in Dockerfile using pnpm cache
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 3m55s
2025-12-13 12:14:20 +01:00
Julien Froidefond
fea940df5f fix: update next and address vulnerabilities
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 20s
2025-12-13 07:26:59 +01:00
Julien Froidefond
3999446509 chore: update DB_HOST in docker-compose.yml to use peakskills-postgres
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 16s
2025-12-11 12:54:36 +01:00
Julien Froidefond
ee83efc317 chore: add public directory to Dockerfile and include .gitkeep for tracking
All checks were successful
Deploy with Docker Compose / deploy (push) Successful in 2m48s
2025-12-11 12:33:03 +01:00
Julien Froidefond
ad7d03ebef chore: add Watchtower label to prevent automatic updates in docker-compose.yml
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 1m40s
2025-12-11 12:11:35 +01:00
Julien Froidefond
bd6f071620 chore: rename services in docker-compose.yml for clarity and update environment variable handling
Some checks failed
Deploy with Docker Compose / deploy (push) Failing after 2m32s
2025-12-11 12:03:48 +01:00
Julien Froidefond
3fd3e165e2 chore: update docker-compose port mapping and upgrade Next.js and dependencies in package.json 2025-12-05 10:53:14 +01:00
Julien Froidefond
a6362b6435 feat: add authentication configuration to docker-compose.yml 2025-10-12 16:16:37 +02:00
Julien Froidefond
6db4921d0f feat: add lint:fix command to package.json and update French text for consistency across various components 2025-10-12 16:09:31 +02:00
Julien Froidefond
ad5d954182 feat: add importance property to category breakdown and update team stats structure 2025-10-12 16:03:11 +02:00
Julien Froidefond
7d12a66c12 refactor: migrate authentication to NextAuth and clean up related services 2025-10-12 15:45:09 +02:00
Julien Froidefond
117ac243f5 refactor: review cookie code for auth 2025-08-28 12:07:35 +02:00
Julien Froidefond
725a368b7e feat: homepage and skills : sorting and coloring 2025-08-27 15:02:06 +02:00
Julien Froidefond
84979501fa fix: risk and colors on covergae in myteam page 2025-08-27 14:57:10 +02:00
Julien Froidefond
85b0cb0a6b feat: review UI of skill matrix in myteam page 2025-08-27 14:50:27 +02:00
Julien Froidefond
a8cad0b2ec refactor: rule of coverage are in one place 2025-08-27 14:31:05 +02:00
68 changed files with 1569 additions and 881 deletions

3
.eslintrc.json Normal file
View File

@@ -0,0 +1,3 @@
{
"extends": "next"
}

View File

@@ -0,0 +1,23 @@
name: Deploy with Docker Compose
on:
push:
branches:
- main
jobs:
deploy:
runs-on: mac-orbstack-runner
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Deploy stack
env:
DOCKER_BUILDKIT: 1
COMPOSE_DOCKER_CLI_BUILD: 1
AUTH_SECRET: ${{ secrets.AUTH_SECRET }}
AUTH_URL: ${{ vars.AUTH_URL }}
INIT_SQL: ${{ vars.INIT_SQL }}
run: |
docker compose up -d --build

View File

@@ -9,8 +9,9 @@ RUN corepack enable && corepack prepare pnpm@latest --activate
# Copie des fichiers de dépendances
COPY package.json pnpm-lock.yaml ./
# Installation des dépendances
RUN pnpm install --frozen-lockfile
# Installation des dépendances avec cache du store pnpm
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile
# Copie du code source
COPY . .
@@ -30,12 +31,14 @@ RUN corepack enable && corepack prepare pnpm@latest --activate
# Copie des dépendances de production
COPY --from=builder /app/package.json /app/pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --prod
RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \
pnpm install --frozen-lockfile --prod
# Copie des fichiers nécessaires depuis le stage de build
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/next.config.mjs ./
# Copie du dossier public (créé avec .gitkeep pour être tracké)
COPY --from=builder /app/public ./public
# Exposition du port
EXPOSE 3000

174
NEXTAUTH_MIGRATION.md Normal file
View File

@@ -0,0 +1,174 @@
# Migration vers NextAuth v5 - Résumé
## Changements effectués
### 1. Installation
- ✅ Installé `next-auth@5.0.0-beta.29`
### 2. Configuration NextAuth
- ✅ Créé `auth.ts` à la racine avec:
- Credentials provider utilisant `userService.verifyCredentials()`
- Stratégie JWT
- Callbacks pour enrichir la session avec `userId`, `teamId`, `firstName`, `lastName`
- Pages custom (`/login`)
- Middleware callback `authorized` pour protéger les routes
- ✅ Créé `app/api/auth/[...nextauth]/route.ts` pour les handlers NextAuth
- ✅ Créé `types/next-auth.d.ts` pour étendre les types Session et User
### 3. Middleware
- ✅ Remplacé le middleware custom par `export { auth as middleware } from "@/auth"`
- ✅ Les redirections sont gérées dans le callback `authorized`
### 4. Routes API
- ✅ Supprimé `app/api/auth/login/route.ts` (géré par NextAuth)
- ✅ Supprimé `app/api/auth/logout/route.ts` (géré par NextAuth)
- ✅ Supprimé `app/api/auth/profile/route.ts` (utiliser `useSession()` côté client)
- ✅ Adapté `app/api/auth/register/route.ts` pour retourner les infos sans créer de session
- ✅ Mis à jour toutes les routes protégées pour utiliser `auth()` au lieu de `AuthService.requireAuthenticatedUser()`
### 5. Services
- ✅ Supprimé `services/auth-service.ts` (remplacé par NextAuth)
- ✅ Mis à jour `services/evaluation-service.ts` pour ne plus utiliser `AuthService`
- ✅ Mis à jour `services/index.ts` pour retirer l'export d'AuthService
### 6. Clients
- ✅ Mis à jour `clients/domains/auth-client.ts` pour utiliser:
- `signIn("credentials")` pour le login
- `signOut()` pour le logout
- Auto-login après register avec `signIn()`
### 7. Frontend
- ✅ Créé `components/auth/session-provider.tsx`
- ✅ Wrapped l'application avec `<SessionProvider>` dans `app/layout.tsx`
- ✅ Mis à jour tous les composants pour utiliser la session NextAuth
- ✅ Mis à jour toutes les pages serveur pour utiliser `auth()` au lieu de `AuthService`
## Configuration requise
### Variables d'environnement
Créer un fichier `.env.local` avec:
```bash
# Générer un secret avec: openssl rand -base64 32
AUTH_SECRET=your-generated-secret-here
# Pour la production
# AUTH_URL=https://your-domain.com
```
### Pour générer AUTH_SECRET
```bash
openssl rand -base64 32
```
## Utilisation
### Côté serveur (Server Components, API Routes)
```typescript
import { auth } from "@/auth";
export default async function Page() {
const session = await auth();
if (!session?.user) {
redirect("/login");
}
const userId = session.user.id;
const teamId = session.user.teamId;
const firstName = session.user.firstName;
// ...
}
```
### Côté client (Client Components)
```typescript
"use client";
import { useSession } from "next-auth/react";
import { signIn, signOut } from "next-auth/react";
export function MyComponent() {
const { data: session, status } = useSession();
if (status === "loading") return <div>Loading...</div>;
if (!session) return <div>Not authenticated</div>;
return (
<div>
<p>Welcome {session.user.firstName}!</p>
<button onClick={() => signOut()}>Logout</button>
</div>
);
}
```
### Login/Register
```typescript
// Login
const result = await signIn("credentials", {
email,
password,
redirect: false,
});
if (result?.error) {
// Handle error
}
// Register (via API puis auto-login)
const response = await fetch("/api/auth/register", {
method: "POST",
body: JSON.stringify(data),
});
if (response.ok) {
await signIn("credentials", { email, password, redirect: false });
}
// Logout
await signOut({ redirect: false });
```
## Compatibilité
### Utilisateurs existants
**Tous les utilisateurs existants sont compatibles** car:
- La table `users` contient déjà `email` et `password_hash`
- La méthode `userService.verifyCredentials()` vérifie les mots de passe hashés avec bcrypt
- Les UUID sont préservés
### Sessions
- ⚠️ Les anciennes sessions (cookie `session_token`) ne sont plus valides
- Les utilisateurs devront se reconnecter une fois
- NextAuth utilise ses propres cookies de session
## Testing
### Vérifications à faire
1. ✅ Login avec email/password
2. ✅ Logout
3. ✅ Register puis auto-login
4. ✅ Routes protégées redirigent vers /login si non authentifié
5. ✅ /login redirige vers / si déjà authentifié
6. ✅ Les évaluations fonctionnent correctement
7. ✅ Les pages admin fonctionnent correctement
## Rollback (si nécessaire)
Si des problèmes surviennent, voici les commits importants:
- Installation de NextAuth
- Création de auth.ts et configuration
- Mise à jour des routes et services
- Suppression de auth-service.ts
Pour rollback: `git revert <commit-hash>` des commits concernés.
## Notes
- NextAuth v5 utilise des cookies signés et sécurisés automatiquement
- Les sessions JWT sont stateless (pas de stockage en base)
- La configuration `authorized` dans auth.ts gère toute la logique de protection des routes
- Le SessionProvider doit wrapper toute l'application pour que `useSession()` fonctionne

View File

@@ -1,18 +1,19 @@
import { redirect } from "next/navigation";
import { AuthService, userService, TeamsService } from "@/services";
import { auth } from "@/auth";
import { userService, TeamsService } from "@/services";
import { AccountForm } from "@/components/account/account-form";
export default async function AccountPage() {
try {
// Vérifier si l'utilisateur est connecté
const userUuid = await AuthService.getUserUuidFromCookie();
const session = await auth();
if (!userUuid) {
if (!session?.user) {
redirect("/login");
}
// Récupérer le profil utilisateur
const userProfile = await userService.getUserByUuid(userUuid);
const userProfile = await userService.getUserByUuid(session.user.id);
if (!userProfile) {
redirect("/login");

View File

@@ -18,7 +18,7 @@ export default async function SkillsPage() {
<div className="container mx-auto p-6">
<div className="flex items-center justify-center h-64">
<div className="text-lg text-red-500">
Erreur lors du chargement des données d'administration
Erreur lors du chargement des données d&apos;administration
</div>
</div>
</div>

View File

@@ -14,7 +14,7 @@ export default async function TeamsPage() {
<div className="container mx-auto p-6">
<div className="flex items-center justify-center h-64">
<div className="text-lg text-red-500">
Erreur lors du chargement des données d'administration
Erreur lors du chargement des données d&apos;administration
</div>
</div>
</div>

View File

@@ -13,7 +13,7 @@ export default async function UsersPage() {
<div className="container mx-auto p-6">
<div className="flex items-center justify-center h-64">
<div className="text-lg text-red-500">
Erreur lors du chargement des données d'administration
Erreur lors du chargement des données d&apos;administration
</div>
</div>
</div>

View File

@@ -20,7 +20,7 @@ export default async function AdminPage() {
<div className="container mx-auto p-6">
<div className="flex items-center justify-center h-64">
<div className="text-lg text-red-500">
Erreur lors du chargement des données d'administration
Erreur lors du chargement des données d&apos;administration
</div>
</div>
</div>

View File

@@ -27,7 +27,7 @@ export default async function TeamDetailPage({ params }: TeamDetailPageProps) {
return (
<div className="min-h-screen bg-gradient-to-br from-slate-950 via-slate-900 to-slate-950 flex items-center justify-center">
<div className="text-white text-xl text-red-500">
Erreur lors du chargement des détails de l'équipe
Erreur lors du chargement des détails de l&apos;équipe
</div>
</div>
);

View File

@@ -0,0 +1,3 @@
import { handlers } from "@/auth";
export const { GET, POST } = handlers;

View File

@@ -1,62 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { AuthService, UserService } from "@/services";
export async function POST(request: NextRequest) {
try {
const { email, password } = await request.json();
// Validation des données
if (!email || !password) {
return NextResponse.json(
{ error: "Email et mot de passe requis" },
{ status: 400 }
);
}
// Vérifier les identifiants
const userService = new UserService();
const user = await userService.verifyCredentials(email, password);
if (!user) {
return NextResponse.json(
{ error: "Email ou mot de passe incorrect" },
{ status: 401 }
);
}
// Générer un token de session
const sessionToken = await AuthService.createSession(user.uuid_id);
// Créer la réponse avec le cookie de session
const response = NextResponse.json(
{
message: "Connexion réussie",
user: {
id: user.uuid_id,
firstName: user.first_name,
lastName: user.last_name,
email: user.email,
teamId: user.team_id,
},
},
{ status: 200 }
);
// Définir le cookie de session
response.cookies.set("session_token", sessionToken, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 60 * 60 * 24 * 7, // 7 jours
path: "/",
});
return response;
} catch (error) {
console.error("Login error:", error);
return NextResponse.json(
{ error: "Erreur interne du serveur" },
{ status: 500 }
);
}
}

View File

@@ -1,29 +0,0 @@
import { NextResponse } from "next/server";
export async function POST() {
try {
// Créer la réponse
const response = NextResponse.json(
{ message: "Déconnexion réussie" },
{ status: 200 }
);
// Supprimer le cookie de session
response.cookies.set("session_token", "", {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 0, // Expire immédiatement
path: "/",
});
return response;
} catch (error) {
console.error("Logout error:", error);
return NextResponse.json(
{ error: "Erreur interne du serveur" },
{ status: 500 }
);
}
}

View File

@@ -1,46 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { AuthService, userService } from "@/services";
export async function PUT(request: NextRequest) {
try {
// Vérifier si l'utilisateur est connecté
const userUuid = await AuthService.getUserUuidFromCookie();
if (!userUuid) {
return NextResponse.json({ error: "Non authentifié" }, { status: 401 });
}
// Récupérer les données de mise à jour
const { firstName, lastName, teamId } = await request.json();
// Validation des données
if (!firstName || !lastName || !teamId) {
return NextResponse.json(
{ error: "Tous les champs sont requis" },
{ status: 400 }
);
}
// Mettre à jour l'utilisateur
await userService.updateUserByUuid(userUuid, {
firstName,
lastName,
teamId,
});
return NextResponse.json({
message: "Profil mis à jour avec succès",
user: {
firstName,
lastName,
teamId,
},
});
} catch (error: any) {
console.error("Profile update error:", error);
return NextResponse.json(
{ error: error.message || "Erreur lors de la mise à jour du profil" },
{ status: 500 }
);
}
}

View File

@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { AuthService, userService } from "@/services";
import { userService } from "@/services";
import bcrypt from "bcryptjs";
export async function POST(request: NextRequest) {
@@ -44,11 +44,9 @@ export async function POST(request: NextRequest) {
);
}
// Générer un token de session
const sessionToken = await AuthService.createSession(newUser.uuid_id);
// Créer la réponse avec le cookie de session
const response = NextResponse.json(
// Retourner les informations de l'utilisateur créé
// Le client devra appeler signIn() pour créer la session
return NextResponse.json(
{
message: "Compte créé avec succès",
user: {
@@ -61,17 +59,6 @@ export async function POST(request: NextRequest) {
},
{ status: 201 }
);
// Définir le cookie de session
response.cookies.set("session_token", sessionToken, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
maxAge: 60 * 60 * 24 * 7, // 7 jours
path: "/",
});
return response;
} catch (error) {
console.error("Register error:", error);
return NextResponse.json(

View File

@@ -1,11 +1,24 @@
import { NextRequest, NextResponse } from "next/server";
import { AuthService } from "@/services/auth-service";
import { auth } from "@/auth";
import { evaluationService } from "@/services/evaluation-service";
export async function PUT(request: NextRequest) {
try {
// Récupérer l'utilisateur depuis le cookie (maintenant un UUID)
const { userProfile } = await AuthService.requireAuthenticatedUser();
// Récupérer l'utilisateur depuis la session NextAuth
const session = await auth();
if (!session?.user) {
return NextResponse.json(
{ error: "Non authentifié" },
{ status: 401 }
);
}
const userProfile = {
firstName: session.user.firstName,
lastName: session.user.lastName,
teamId: session.user.teamId,
};
const body = await request.json();
const { category, skillId, level, canMentor, wantsToLearn, action } = body;

View File

@@ -1,13 +1,20 @@
import { NextRequest, NextResponse } from "next/server";
import { TeamReviewService } from "@/services/team-review-service";
import { AuthService } from "@/services/auth-service";
import { auth } from "@/auth";
export async function GET(request: NextRequest) {
try {
// Vérifier l'authentification
const { userProfile } = await AuthService.requireAuthenticatedUser();
const session = await auth();
const teamId = userProfile.teamId;
if (!session?.user) {
return NextResponse.json(
{ error: "Non authentifié" },
{ status: 401 }
);
}
const teamId = session.user.teamId;
const data = await TeamReviewService.getTeamReviewData(teamId);
return NextResponse.json(data);

View File

@@ -1,5 +1,5 @@
import { redirect } from "next/navigation";
import { AuthService } from "@/services";
import { auth } from "@/auth";
import { SkillsService, TeamsService } from "@/services";
import { evaluationService } from "@/services/evaluation-service";
import { EvaluationClientWrapper } from "@/components/evaluation";
@@ -7,12 +7,14 @@ import { SkillEvaluation } from "@/components/skill-evaluation";
export default async function EvaluationPage() {
// Charger les données côté serveur
const userUuid = await AuthService.getUserUuidFromCookie();
const session = await auth();
if (!userUuid) {
if (!session?.user) {
redirect("/login");
}
const userUuid = session.user.id;
const [userEvaluation, skillCategories, teams] = await Promise.all([
evaluationService.getServerUserEvaluation(userUuid!),
SkillsService.getSkillCategories(),

View File

@@ -7,7 +7,9 @@ import { ThemeProvider } from "@/components/layout/theme-provider";
import { Toaster } from "@/components/ui/sonner";
import { UserProvider } from "@/hooks/use-user-context";
import { NavigationWrapper } from "@/components/layout/navigation-wrapper";
import { AuthService, TeamsService } from "@/services";
import { SessionProvider } from "@/components/auth/session-provider";
import { auth } from "@/auth";
import { TeamsService } from "@/services";
export const metadata: Metadata = {
title: "PeakSkills - Auto-évaluation de compétences",
@@ -20,32 +22,33 @@ export default async function RootLayout({
}: {
children: React.ReactNode;
}) {
// Récupérer les infos utilisateur côté serveur
// Récupérer les infos utilisateur depuis la session NextAuth
let userInfo = null;
try {
const { userUuid, userProfile } =
await AuthService.requireAuthenticatedUser();
const session = await auth();
// Récupérer le nom de l'équipe
let teamName = "Équipe non définie";
if (userProfile.teamId) {
try {
const team = await TeamsService.getTeamById(userProfile.teamId);
if (team) {
teamName = team.name;
if (session?.user) {
// Récupérer le nom de l'équipe
let teamName = "Équipe non définie";
if (session.user.teamId) {
try {
const team = await TeamsService.getTeamById(session.user.teamId);
if (team) {
teamName = team.name;
}
} catch (error) {
console.error("Failed to fetch team name:", error);
}
} catch (error) {
console.error("Failed to fetch team name:", error);
}
}
userInfo = {
firstName: userProfile.firstName,
lastName: userProfile.lastName,
teamName,
teamId: userProfile.teamId,
uuid: userUuid,
};
userInfo = {
firstName: session.user.firstName,
lastName: session.user.lastName,
teamName,
teamId: session.user.teamId,
uuid: session.user.id,
};
}
} catch (error) {
// Utilisateur non authentifié, userInfo reste null
console.log("User not authenticated:", error);
@@ -56,18 +59,20 @@ export default async function RootLayout({
<body
className={`${GeistSans.variable} ${GeistMono.variable} antialiased`}
>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<UserProvider initialUserInfo={userInfo}>
<NavigationWrapper />
<main className="min-h-screen">{children}</main>
<Toaster />
</UserProvider>
</ThemeProvider>
<SessionProvider>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
<UserProvider initialUserInfo={userInfo}>
<NavigationWrapper />
<main className="min-h-screen">{children}</main>
<Toaster />
</UserProvider>
</ThemeProvider>
</SessionProvider>
</body>
</html>
);

View File

@@ -1,5 +1,5 @@
import { TeamsService, userService } from "@/services";
import { AuthService } from "@/services";
import { auth } from "@/auth";
import { LoginLayout, AuthWrapper, LoginLoading } from "@/components/login";
export default async function LoginPage() {
@@ -8,13 +8,13 @@ export default async function LoginPage() {
const teams = await TeamsService.getTeams();
// Vérifier si l'utilisateur est déjà connecté
const userUuid = await AuthService.getUserUuidFromCookie();
const session = await auth();
let userProfile = null;
if (userUuid) {
if (session?.user) {
// Si l'utilisateur est connecté, récupérer son profil côté serveur
userProfile = await userService.getUserByUuid(userUuid);
userProfile = await userService.getUserByUuid(session.user.id);
}
// Si l'utilisateur n'est pas connecté, afficher le formulaire d'auth

View File

@@ -1,5 +1,5 @@
import { redirect } from "next/navigation";
import { AuthService } from "@/services";
import { auth } from "@/auth";
import { evaluationService, SkillsService, TeamsService } from "@/services";
import { generateRadarData } from "@/lib/evaluation-utils";
import {
@@ -14,12 +14,14 @@ import {
export default async function HomePage() {
// Charger les données côté serveur
const userUuid = await AuthService.getUserUuidFromCookie();
const session = await auth();
if (!userUuid) {
if (!session?.user) {
redirect("/login");
}
const userUuid = session.user.id;
const [userEvaluation, skillCategories, teams] = await Promise.all([
evaluationService.getServerUserEvaluation(userUuid!),
SkillsService.getSkillCategories(),

View File

@@ -1,5 +1,5 @@
import { TeamReviewService } from "@/services/team-review-service";
import { AuthService } from "@/services/auth-service";
import { auth } from "@/auth";
import { redirect } from "next/navigation";
import { TeamOverview } from "@/components/team-review/team-overview";
import { SkillMatrix } from "@/components/team-review/skill-matrix";
@@ -13,10 +13,14 @@ export const dynamic = "force-dynamic";
async function TeamReviewPage() {
try {
const { userProfile } = await AuthService.requireAuthenticatedUser();
const session = await auth();
if (!session?.user) {
redirect("/login");
}
const teamData = await TeamReviewService.getTeamReviewData(
userProfile.teamId
session.user.teamId
);
return (
@@ -32,7 +36,7 @@ async function TeamReviewPage() {
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-white/5 border border-white/10 backdrop-blur-sm">
<Users className="h-4 w-4 text-blue-400" />
<span className="text-sm font-medium text-slate-200">
Vue dquipe
Vue d&apos;équipe
</span>
</div>
@@ -41,7 +45,7 @@ async function TeamReviewPage() {
</h1>
<p className="text-slate-400 max-w-2xl mx-auto leading-relaxed">
Vue d'ensemble et analyse des compétences de l'équipe{" "}
Vue d&apos;ensemble et analyse des compétences de l&apos;équipe{" "}
{teamData.team.direction}
</p>
</div>

69
auth.ts Normal file
View File

@@ -0,0 +1,69 @@
import NextAuth from "next-auth";
import Credentials from "next-auth/providers/credentials";
export const { handlers, signIn, signOut, auth } = NextAuth({
providers: [
Credentials({
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
if (!credentials?.email || !credentials?.password) {
return null;
}
// Import dynamique pour éviter l'erreur Edge Runtime dans le middleware
const { userService } = await import("@/services/user-service");
const user = await userService.verifyCredentials(
credentials.email as string,
credentials.password as string
);
if (!user) {
return null;
}
// Retourner l'utilisateur au format attendu par NextAuth
return {
id: user.uuid_id,
email: user.email,
name: `${user.first_name} ${user.last_name}`,
firstName: user.first_name,
lastName: user.last_name,
teamId: user.team_id,
};
},
}),
],
pages: {
signIn: "/login",
},
session: {
strategy: "jwt",
},
callbacks: {
async jwt({ token, user }) {
// Lors de la première connexion, ajouter les infos custom au token
if (user) {
token.id = user.id;
token.teamId = (user as any).teamId;
token.firstName = (user as any).firstName;
token.lastName = (user as any).lastName;
}
return token;
},
async session({ session, token }) {
// Ajouter les infos du token à la session
if (token && session.user) {
session.user.id = token.id as string;
session.user.teamId = token.teamId as string;
session.user.firstName = token.firstName as string;
session.user.lastName = token.lastName as string;
}
return session;
},
},
});

View File

@@ -1,5 +1,5 @@
import { signIn, signOut } from "next-auth/react";
import { BaseHttpClient } from "../base/http-client";
import { UserProfile } from "../../lib/types";
export interface LoginCredentials {
email: string;
@@ -24,27 +24,54 @@ export interface AuthUser {
export class AuthClient extends BaseHttpClient {
/**
* Connecte un utilisateur avec email/password
* Connecte un utilisateur avec email/password via NextAuth
*/
async login(
credentials: LoginCredentials
): Promise<{ user: AuthUser; message: string }> {
return await this.post("/auth/login", credentials);
async login(credentials: LoginCredentials): Promise<{ success: boolean; error?: string }> {
const result = await signIn("credentials", {
email: credentials.email,
password: credentials.password,
redirect: false,
});
if (result?.error) {
return { success: false, error: "Email ou mot de passe incorrect" };
}
return { success: true };
}
/**
* Crée un nouveau compte utilisateur
* Crée un nouveau compte utilisateur puis se connecte automatiquement
*/
async register(
data: RegisterData
): Promise<{ user: AuthUser; message: string }> {
return await this.post("/auth/register", data);
async register(data: RegisterData): Promise<{ success: boolean; error?: string }> {
try {
// Créer l'utilisateur via l'API
const response = await this.post<{ user: AuthUser; message: string }>("/auth/register", data);
// Se connecter automatiquement après création
const loginResult = await signIn("credentials", {
email: data.email,
password: data.password,
redirect: false,
});
if (loginResult?.error) {
return { success: false, error: "Compte créé mais erreur de connexion" };
}
return { success: true };
} catch (error: any) {
return {
success: false,
error: error.message || "Erreur lors de la création du compte"
};
}
}
/**
* Déconnecte l'utilisateur
* Déconnecte l'utilisateur via NextAuth
*/
async logout(): Promise<{ message: string }> {
return await this.post("/auth/logout");
async logout(): Promise<void> {
await signOut({ redirect: false });
}
}

View File

@@ -254,7 +254,7 @@ export function AccountForm({ initialProfile, teams }: AccountFormProps) {
{Object.keys(teamsByDirection).length === 0 && (
<div className="px-3 py-4 text-center text-sm text-muted-foreground">
Aucune équipe trouvée pour "{searchTerm}"
Aucune équipe trouvée pour &quot;{searchTerm}&quot;
</div>
)}
</div>

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useEffect, useCallback } from "react";
import { Users, User, Calendar, X, Trash2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
@@ -31,13 +31,7 @@ export function TeamMembersModal({
const [deletingMemberId, setDeletingMemberId] = useState<string | null>(null);
const { toast } = useToast();
useEffect(() => {
if (isOpen && teamId) {
fetchMembers();
}
}, [isOpen, teamId]);
const fetchMembers = async () => {
const fetchMembers = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
@@ -48,7 +42,13 @@ export function TeamMembersModal({
} finally {
setIsLoading(false);
}
};
}, [teamId]);
useEffect(() => {
if (isOpen && teamId) {
fetchMembers();
}
}, [isOpen, teamId, fetchMembers]);
const handleRemoveMember = async (memberId: string, memberName: string) => {
if (
@@ -91,7 +91,7 @@ export function TeamMembersModal({
<div className="flex items-center gap-3">
<Users className="w-6 h-6 text-blue-400" />
<div>
<CardTitle className="text-xl">Membres de l'équipe</CardTitle>
<CardTitle className="text-xl">Membres de l&apos;équipe</CardTitle>
<p className="text-sm text-slate-500 font-normal">{teamName}</p>
</div>
</div>
@@ -137,7 +137,7 @@ export function TeamMembersModal({
Aucun membre dans cette équipe
</p>
<p className="text-slate-500 text-sm mt-1">
Les membres apparaîtront ici une fois qu'ils seront assignés
Les membres apparaîtront ici une fois qu&apos;ils seront assignés
</p>
</div>
) : (

View File

@@ -6,6 +6,10 @@ import {
getSkillLevelLabel,
getSkillLevelColor,
} from "../team-detail/team-stats-row";
import {
COVERAGE_OBJECTIVES,
isCoverageBelowObjective,
} from "@/lib/evaluation-utils";
interface DirectionOverviewProps {
direction: string;
@@ -182,7 +186,10 @@ export function DirectionOverview({
</span>
<span
className={`text-sm font-bold ${
averageCriticalCoverage.incontournable < 75
isCoverageBelowObjective(
averageCriticalCoverage.incontournable,
"incontournable"
)
? "text-red-400"
: "text-green-400"
}`}
@@ -193,7 +200,10 @@ export function DirectionOverview({
<div className="w-full bg-slate-700/50 rounded-full h-1.5">
<div
className={`h-1.5 rounded-full transition-all shadow-sm ${
averageCriticalCoverage.incontournable < 75
isCoverageBelowObjective(
averageCriticalCoverage.incontournable,
"incontournable"
)
? "bg-gradient-to-r from-red-500 to-red-400"
: "bg-gradient-to-r from-green-500 to-green-400"
}`}
@@ -210,7 +220,10 @@ export function DirectionOverview({
<span className="text-sm text-slate-300">Majeures:</span>
<span
className={`text-sm font-bold ${
averageCriticalCoverage.majeure < 60
isCoverageBelowObjective(
averageCriticalCoverage.majeure,
"majeure"
)
? "text-orange-400"
: "text-green-400"
}`}
@@ -221,7 +234,10 @@ export function DirectionOverview({
<div className="w-full bg-slate-700/50 rounded-full h-1.5">
<div
className={`h-1.5 rounded-full transition-all shadow-sm ${
averageCriticalCoverage.majeure < 60
isCoverageBelowObjective(
averageCriticalCoverage.majeure,
"majeure"
)
? "bg-gradient-to-r from-orange-500 to-orange-400"
: "bg-gradient-to-r from-green-500 to-green-400"
}`}

View File

@@ -6,6 +6,10 @@ import { TeamDetailHeader } from "./team-detail-header";
import { TeamMetricsCards } from "./team-metrics-cards";
import { TeamDetailTabs } from "./team-detail-tabs";
import { TeamMemberModal } from "@/components/admin";
import {
COVERAGE_OBJECTIVES,
isCoverageBelowObjective,
} from "@/lib/evaluation-utils";
interface TeamDetailClientWrapperProps {
team: TeamStats;
@@ -185,10 +189,14 @@ export function TeamDetailClientWrapper({
),
skillGaps: {
incontournable: skillAnalysis.filter(
(s) => s.importance === "incontournable" && s.coverage < 75
(s) =>
s.importance === "incontournable" &&
isCoverageBelowObjective(s.coverage, s.importance)
).length,
majeure: skillAnalysis.filter(
(s) => s.importance === "majeure" && s.coverage < 60
(s) =>
s.importance === "majeure" &&
isCoverageBelowObjective(s.coverage, s.importance)
).length,
standard: skillAnalysis.filter(
(s) => s.importance === "standard" && s.averageLevel < 1.5
@@ -196,10 +204,14 @@ export function TeamDetailClientWrapper({
},
strongSkills: {
incontournable: skillAnalysis.filter(
(s) => s.importance === "incontournable" && s.coverage >= 75
(s) =>
s.importance === "incontournable" &&
!isCoverageBelowObjective(s.coverage, s.importance)
).length,
majeure: skillAnalysis.filter(
(s) => s.importance === "majeure" && s.coverage >= 60
(s) =>
s.importance === "majeure" &&
!isCoverageBelowObjective(s.coverage, s.importance)
).length,
standard: skillAnalysis.filter(
(s) => s.importance === "standard" && s.averageLevel >= 2.5

View File

@@ -27,7 +27,7 @@ export function TeamDetailHeader({
className="text-slate-400 hover:text-white hover:bg-white/10"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Retour à l'admin
Retour à l&apos;admin
</Button>
<div className="text-slate-400 text-sm">|</div>
<div className="flex items-center gap-3">

View File

@@ -14,6 +14,10 @@ import { Badge } from "@/components/ui/badge";
import { Users, ExternalLink, Download, Eye } from "lucide-react";
import { TeamMember } from "@/lib/admin-types";
import {
COVERAGE_OBJECTIVES,
isCoverageBelowObjective,
} from "@/lib/evaluation-utils";
interface TeamDetailModalProps {
isOpen: boolean;
@@ -112,7 +116,7 @@ export function TeamDetailModal({
</div>
</DialogTitle>
<DialogDescription className="text-slate-400">
Aperçu rapide de l'équipe. Cliquez sur "Voir tous les détails" pour
Aperçu rapide de l&apos;équipe. Cliquez sur &quot;Voir tous les détails&quot; pour
une vue complète.
</DialogDescription>
</DialogHeader>
@@ -157,7 +161,10 @@ export function TeamDetailModal({
</span>
<span
className={`text-sm font-bold ${
team.criticalSkillsCoverage.incontournable < 75
isCoverageBelowObjective(
team.criticalSkillsCoverage.incontournable,
"incontournable"
)
? "text-red-400"
: "text-green-400"
}`}
@@ -168,7 +175,10 @@ export function TeamDetailModal({
<div className="w-full bg-slate-700/50 rounded-full h-1.5">
<div
className={`h-1.5 rounded-full transition-all ${
team.criticalSkillsCoverage.incontournable < 75
isCoverageBelowObjective(
team.criticalSkillsCoverage.incontournable,
"incontournable"
)
? "bg-red-500"
: "bg-green-500"
}`}
@@ -182,7 +192,10 @@ export function TeamDetailModal({
<span className="text-sm text-slate-300">Majeures</span>
<span
className={`text-sm font-bold ${
team.criticalSkillsCoverage.majeure < 60
isCoverageBelowObjective(
team.criticalSkillsCoverage.majeure,
"majeure"
)
? "text-red-400"
: "text-green-400"
}`}
@@ -193,7 +206,10 @@ export function TeamDetailModal({
<div className="w-full bg-slate-700/50 rounded-full h-1.5">
<div
className={`h-1.5 rounded-full transition-all ${
team.criticalSkillsCoverage.majeure < 60
isCoverageBelowObjective(
team.criticalSkillsCoverage.majeure,
"majeure"
)
? "bg-red-500"
: "bg-green-500"
}`}
@@ -209,13 +225,11 @@ export function TeamDetailModal({
<h3 className="font-medium text-white mb-3">Top 3 Compétences</h3>
<div className="space-y-2">
{team.topSkills.slice(0, 3).map((skill, idx) => {
const target =
skill.importance === "incontournable"
? 75
: skill.importance === "majeure"
? 60
: 0;
const isUnderTarget = target > 0 && skill.coverage < target;
const target = COVERAGE_OBJECTIVES[skill.importance];
const isUnderTarget = isCoverageBelowObjective(
skill.coverage,
skill.importance
);
return (
<div

View File

@@ -69,7 +69,7 @@ export function TeamDetailTabs({
value="overview"
className="data-[state=active]:bg-white/20 data-[state=active]:text-white text-slate-400 hover:text-white transition-colors"
>
Vue d'ensemble
Vue d&apos;ensemble
</TabsTrigger>
<TabsTrigger
value="skills"

View File

@@ -1,6 +1,10 @@
"use client";
import { TrendingUp, MessageSquare, Lightbulb } from "lucide-react";
import {
COVERAGE_OBJECTIVES,
isCoverageBelowObjective,
} from "@/lib/evaluation-utils";
interface SkillAnalysis {
skillName: string;
@@ -64,9 +68,7 @@ export function TeamInsightsTab({
<div className="space-y-1">
{/* Incontournables */}
{skillAnalysis
.filter(
(s) => s.importance === "incontournable" && s.coverage < 75
)
.filter((s) => isCoverageBelowObjective(s.coverage, s.importance))
.map((skill, idx) => (
<div
key={idx}
@@ -83,7 +85,7 @@ export function TeamInsightsTab({
</div>
<div className="flex items-center gap-2">
<div className="text-[10px] text-red-300 opacity-0 group-hover:opacity-100 transition-opacity">
Objectif: 75%
Objectif: {COVERAGE_OBJECTIVES[skill.importance]}%
</div>
<div className="text-xs text-red-400 font-medium">
{skill.coverage.toFixed(0)}%
@@ -95,7 +97,7 @@ export function TeamInsightsTab({
{/* Majeures */}
{skillAnalysis
.filter((s) => s.importance === "majeure" && s.coverage < 60)
.filter((s) => isCoverageBelowObjective(s.coverage, s.importance))
.map((skill, idx) => (
<div
key={idx}
@@ -112,7 +114,7 @@ export function TeamInsightsTab({
</div>
<div className="flex items-center gap-2">
<div className="text-[10px] text-blue-300 opacity-0 group-hover:opacity-100 transition-opacity">
Objectif: 60%
Objectif: {COVERAGE_OBJECTIVES[skill.importance]}%
</div>
<div className="text-xs text-blue-400 font-medium">
{skill.coverage.toFixed(0)}%
@@ -202,7 +204,7 @@ export function TeamInsightsTab({
<div className="bg-white/5 backdrop-blur-sm border border-white/10 rounded-2xl p-6">
<h3 className="text-lg font-semibold text-white mb-6 flex items-center gap-2">
<Lightbulb className="h-5 w-5 text-yellow-400" />
Recommandations pour lquipe
Recommandations pour l&apos;équipe
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="p-4 bg-red-500/10 border border-red-500/20 rounded-xl">
@@ -217,7 +219,7 @@ export function TeamInsightsTab({
{teamInsights.skillGaps.incontournable > 1 ? "s" : ""}{" "}
incontournable
{teamInsights.skillGaps.incontournable > 1 ? "s" : ""} sous
l'objectif de 75%.
l&apos;objectif de {COVERAGE_OBJECTIVES.incontournable}%.
</>
) : (
<>
@@ -237,9 +239,9 @@ export function TeamInsightsTab({
<>
Attention: {teamInsights.skillGaps.majeure} compétence
{teamInsights.skillGaps.majeure > 1 ? "s" : ""} majeure
{teamInsights.skillGaps.majeure > 1 ? "s" : ""} n'atteigne
{teamInsights.skillGaps.majeure > 1 ? "s" : ""} n&apos;atteigne
{teamInsights.skillGaps.majeure > 1 ? "nt" : ""} pas
l'objectif de 60%.
l&apos;objectif de {COVERAGE_OBJECTIVES.majeure}%.
</>
) : (
<>

View File

@@ -1,6 +1,10 @@
"use client";
import { Users, BarChart3, Award, BookOpen, Target } from "lucide-react";
import {
COVERAGE_OBJECTIVES,
isCoverageBelowObjective,
} from "@/lib/evaluation-utils";
interface TeamInsights {
averageTeamLevel: number;
@@ -81,7 +85,10 @@ export function TeamMetricsCards({
</div>
<div
className={`text-2xl font-bold ${
teamInsights.criticalSkillsCoverage.incontournable < 75
isCoverageBelowObjective(
teamInsights.criticalSkillsCoverage.incontournable,
"incontournable"
)
? "text-red-400"
: "text-green-400"
}`}
@@ -102,7 +109,10 @@ export function TeamMetricsCards({
</div>
<div
className={`text-2xl font-bold ${
teamInsights.criticalSkillsCoverage.majeure < 60
isCoverageBelowObjective(
teamInsights.criticalSkillsCoverage.majeure,
"majeure"
)
? "text-red-400"
: "text-green-400"
}`}

View File

@@ -3,11 +3,16 @@
import { BarChart3, Target, Star } from "lucide-react";
import { TeamStats } from "@/lib/admin-types";
import { TechIcon } from "@/components/icons/tech-icon";
import {
COVERAGE_OBJECTIVES,
isCoverageBelowObjective,
} from "@/lib/evaluation-utils";
interface SkillAnalysis {
skillName: string;
category: string;
importance: "incontournable" | "majeure" | "standard";
icon?: string;
experts: Array<{
name: string;
level: number;
@@ -69,7 +74,7 @@ export function TeamOverviewTab({
<div className="bg-white/5 backdrop-blur-sm border border-white/10 rounded-2xl p-6">
<h3 className="text-lg font-semibold text-white mb-6 flex items-center gap-2">
<Star className="h-5 w-5 text-yellow-400" />
Top Compétences de lquipe
Top Compétences de l&apos;équipe
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{team.topSkills.slice(0, 6).map((skill, idx) => (
@@ -125,15 +130,14 @@ export function TeamOverviewTab({
<div className="flex items-center gap-2">
<div
className={`text-xs ${
skill.importance === "incontournable"
? skill.coverage < 75
? "text-red-400"
: "text-green-400"
: skill.importance === "majeure"
? skill.coverage < 60
? "text-red-400"
: "text-green-400"
: "text-slate-400"
skill.importance === "standard"
? "text-slate-400"
: isCoverageBelowObjective(
skill.coverage,
skill.importance
)
? "text-red-400"
: "text-green-400"
}`}
>
{skill.coverage.toFixed(0)}%
@@ -231,7 +235,10 @@ export function TeamOverviewTab({
<div className="flex items-center gap-3">
<span
className={`text-sm font-medium ${
skill.coverage < 75
isCoverageBelowObjective(
skill.coverage,
skill.importance
)
? "text-red-400"
: "text-green-400"
}`}
@@ -241,7 +248,12 @@ export function TeamOverviewTab({
<div className="w-16 bg-white/10 rounded-full h-1.5">
<div
className={`h-1.5 rounded-full ${
skill.coverage < 75 ? "bg-red-500" : "bg-green-500"
isCoverageBelowObjective(
skill.coverage,
skill.importance
)
? "bg-red-500"
: "bg-green-500"
}`}
style={{ width: `${skill.coverage}%` }}
/>
@@ -292,7 +304,10 @@ export function TeamOverviewTab({
<div className="flex items-center gap-3">
<span
className={`text-sm font-medium ${
skill.coverage < 60
isCoverageBelowObjective(
skill.coverage,
skill.importance
)
? "text-red-400"
: "text-green-400"
}`}
@@ -302,7 +317,12 @@ export function TeamOverviewTab({
<div className="w-16 bg-white/10 rounded-full h-1.5">
<div
className={`h-1.5 rounded-full ${
skill.coverage < 60 ? "bg-red-500" : "bg-green-500"
isCoverageBelowObjective(
skill.coverage,
skill.importance
)
? "bg-red-500"
: "bg-green-500"
}`}
style={{ width: `${skill.coverage}%` }}
/>
@@ -404,7 +424,10 @@ export function TeamOverviewTab({
<div className="flex items-center gap-3">
<span
className={`text-sm font-bold ${
teamInsights.criticalSkillsCoverage.incontournable < 75
isCoverageBelowObjective(
teamInsights.criticalSkillsCoverage.incontournable,
"incontournable"
)
? "text-red-400"
: "text-green-400"
}`}
@@ -417,7 +440,10 @@ export function TeamOverviewTab({
<div className="w-16 bg-white/10 rounded-full h-2">
<div
className={`h-2 rounded-full ${
teamInsights.criticalSkillsCoverage.incontournable < 75
isCoverageBelowObjective(
teamInsights.criticalSkillsCoverage.incontournable,
"incontournable"
)
? "bg-red-500"
: "bg-green-500"
}`}
@@ -437,7 +463,10 @@ export function TeamOverviewTab({
<div className="flex items-center gap-3">
<span
className={`text-sm font-bold ${
teamInsights.criticalSkillsCoverage.majeure < 60
isCoverageBelowObjective(
teamInsights.criticalSkillsCoverage.majeure,
"majeure"
)
? "text-red-400"
: "text-green-400"
}`}
@@ -447,7 +476,10 @@ export function TeamOverviewTab({
<div className="w-16 bg-white/10 rounded-full h-2">
<div
className={`h-2 rounded-full ${
teamInsights.criticalSkillsCoverage.majeure < 60
isCoverageBelowObjective(
teamInsights.criticalSkillsCoverage.majeure,
"majeure"
)
? "bg-red-500"
: "bg-green-500"
}`}
@@ -473,7 +505,7 @@ export function TeamOverviewTab({
</div>
<div className="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<span className="text-slate-300">Objectifs d'apprentissage</span>
<span className="text-slate-300">Objectifs d&apos;apprentissage</span>
<span className="text-white font-bold">
{teamInsights.totalLearners}
</span>

View File

@@ -3,6 +3,10 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
COVERAGE_OBJECTIVES,
isCoverageBelowObjective,
} from "@/lib/evaluation-utils";
interface SkillAnalysis {
skillName: string;
@@ -124,13 +128,11 @@ export function TeamSkillsTab({ skillAnalysis }: TeamSkillsTabProps) {
</thead>
<tbody>
{filteredSkills.map((skill, idx) => {
const target =
skill.importance === "incontournable"
? 75
: skill.importance === "majeure"
? 60
: 0;
const isUnderTarget = target > 0 && skill.coverage < target;
const target = COVERAGE_OBJECTIVES[skill.importance];
const isUnderTarget = isCoverageBelowObjective(
skill.coverage,
skill.importance
);
return (
<tr

View File

@@ -12,6 +12,10 @@ import {
ChevronRight,
} from "lucide-react";
import { TechIcon } from "@/components/icons/tech-icon";
import {
COVERAGE_OBJECTIVES,
isCoverageBelowObjective,
} from "@/lib/evaluation-utils";
interface TeamStatsCardProps {
teamId: string;
@@ -24,6 +28,8 @@ interface TeamStatsCardProps {
averageLevel: number;
color?: string;
icon?: string;
importance: "incontournable" | "majeure" | "standard";
coverage: number;
}>;
skillCoverage: number;
onViewDetails?: () => void;
@@ -52,10 +58,11 @@ export function getSkillLevelBadgeClasses(level: number): string {
return "bg-green-500/20 border-green-500/30 text-green-300";
}
export function getProgressColor(percentage: number): string {
if (percentage < 30) return "bg-red-500";
if (percentage < 60) return "bg-orange-500";
if (percentage < 80) return "bg-blue-500";
export function getProgressColor(
percentage: number,
importance: "incontournable" | "majeure" | "standard"
): string {
if (isCoverageBelowObjective(percentage, importance)) return "bg-red-500";
return "bg-green-500";
}

View File

@@ -24,6 +24,10 @@ import {
} from "lucide-react";
import { TechIcon } from "@/components/icons/tech-icon";
import { getImportanceColors } from "@/lib/tech-colors";
import {
COVERAGE_OBJECTIVES,
isCoverageBelowObjective,
} from "@/lib/evaluation-utils";
interface TeamStatsRowProps {
teamId: string;
@@ -70,10 +74,11 @@ export function getSkillLevelBadgeClasses(level: number): string {
return "bg-green-500/20 border-green-500/30 text-green-300";
}
export function getProgressColor(percentage: number): string {
if (percentage < 30) return "bg-red-500";
if (percentage < 60) return "bg-orange-500";
if (percentage < 80) return "bg-blue-500";
export function getProgressColor(
percentage: number,
importance: "incontournable" | "majeure" | "standard"
): string {
if (isCoverageBelowObjective(percentage, importance)) return "bg-red-500";
return "bg-green-500";
}
@@ -90,8 +95,14 @@ export function TeamStatsRow({
onViewReport,
}: TeamStatsRowProps) {
// Calculer les alertes sur les compétences critiques
const hasIncontournableAlert = criticalSkillsCoverage.incontournable < 75;
const hasMajeureAlert = criticalSkillsCoverage.majeure < 60;
const hasIncontournableAlert = isCoverageBelowObjective(
criticalSkillsCoverage.incontournable,
"incontournable"
);
const hasMajeureAlert = isCoverageBelowObjective(
criticalSkillsCoverage.majeure,
"majeure"
);
return (
<div className="group bg-gradient-to-r from-slate-800/50 to-slate-700/40 backdrop-blur-sm border border-slate-600/40 rounded-xl p-4 hover:from-slate-700/60 hover:to-slate-600/50 hover:border-slate-500/50 transition-all duration-300 shadow-lg hover:shadow-xl">
@@ -144,7 +155,10 @@ export function TeamStatsRow({
<div className="text-center">
<div
className={`text-sm font-bold ${
criticalSkillsCoverage.incontournable < 75
isCoverageBelowObjective(
criticalSkillsCoverage.incontournable,
"incontournable"
)
? "text-red-400"
: "text-green-400"
}`}
@@ -158,7 +172,7 @@ export function TeamStatsRow({
<p className="text-xs">
Couverture des compétences incontournables
<br />
Objectif : 75%
Objectif : {COVERAGE_OBJECTIVES.incontournable}%
</p>
</TooltipContent>
</Tooltip>
@@ -170,7 +184,10 @@ export function TeamStatsRow({
<div className="text-center">
<div
className={`text-sm font-bold ${
criticalSkillsCoverage.majeure < 60
isCoverageBelowObjective(
criticalSkillsCoverage.majeure,
"majeure"
)
? "text-red-400"
: "text-green-400"
}`}
@@ -184,7 +201,7 @@ export function TeamStatsRow({
<p className="text-xs">
Couverture des compétences majeures
<br />
Objectif : 60%
Objectif : {COVERAGE_OBJECTIVES.majeure}%
</p>
</TooltipContent>
</Tooltip>
@@ -222,13 +239,11 @@ export function TeamStatsRow({
<div className="flex items-center gap-2">
{topSkills.slice(0, 3).map((skill, idx) => {
const colors = getImportanceColors(skill.importance);
const target =
skill.importance === "incontournable"
? 75
: skill.importance === "majeure"
? 60
: 0;
const isUnderTarget = target > 0 && skill.coverage < target;
const target = COVERAGE_OBJECTIVES[skill.importance];
const isUnderTarget = isCoverageBelowObjective(
skill.coverage,
skill.importance
);
return (
<TooltipProvider key={idx}>

View File

@@ -55,7 +55,7 @@ export function TeamFormDialog({
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="team-name">Nom de l'équipe *</Label>
<Label htmlFor="team-name">Nom de l&apos;équipe *</Label>
<Input
id="team-name"
value={formData.name}

View File

@@ -23,7 +23,7 @@ export function AdminHeader() {
<h1 className="text-4xl font-bold text-white">Dashboard Managérial</h1>
<p className="text-slate-400 max-w-2xl mx-auto leading-relaxed">
Vue d'ensemble des compétences par équipe et direction pour pilotage
Vue d&apos;ensemble des compétences par équipe et direction pour pilotage
stratégique
</p>
@@ -40,7 +40,7 @@ export function AdminHeader() {
}`}
>
<Building2 className="w-4 h-4 mr-2" />
Vue d'ensemble
Vue d&apos;ensemble
</Button>
</Link>
<Link href="/admin/manage">

View File

@@ -0,0 +1,13 @@
"use client";
import { SessionProvider as NextAuthSessionProvider } from "next-auth/react";
import { ReactNode } from "react";
interface SessionProviderProps {
children: ReactNode;
}
export function SessionProvider({ children }: SessionProviderProps) {
return <NextAuthSessionProvider>{children}</NextAuthSessionProvider>;
}

View File

@@ -9,7 +9,7 @@ export function ActionSection() {
size="lg"
className="bg-blue-500 hover:bg-blue-600 text-white px-8 py-3 rounded-xl"
>
<Link href="/evaluation">Continuer l'évaluation</Link>
<Link href="/evaluation">Continuer l&apos;évaluation</Link>
</Button>
</div>
);

View File

@@ -23,6 +23,7 @@ interface CategoryBreakdownProps {
id: string;
name: string;
icon?: string;
importance: "incontournable" | "majeure" | "standard";
}>;
}>;
}

View File

@@ -5,6 +5,7 @@ import { Button } from "@/components/ui/button";
import { ChevronDown, ChevronRight, ExternalLink } from "lucide-react";
import { getCategoryIcon } from "@/lib/category-icons";
import { getScoreColors } from "@/lib/score-utils";
import { getImportanceColors } from "@/lib/tech-colors";
import { SkillProgress } from "./skill-progress";
import Link from "next/link";
@@ -29,6 +30,7 @@ interface CategoryCardProps {
id: string;
name: string;
icon?: string;
importance: "incontournable" | "majeure" | "standard";
}>;
};
}
@@ -114,23 +116,39 @@ export function CategoryCard({
<div className="px-3 pb-3 border-t border-white/10 bg-white/5">
{categoryEval && skillCategory && skillsCount > 0 ? (
<div className="pt-3 space-y-2 max-h-60 overflow-y-auto">
{categoryEval.selectedSkillIds.map((skillId) => {
const skill = skillCategory.skills.find(
(s) => s.id === skillId
);
if (!skill) return null;
const skillEval = categoryEval.skills.find(
(s) => s.skillId === skillId
);
return (
<SkillProgress
key={skillId}
skill={skill}
skillEval={skillEval}
/>
);
})}
{categoryEval.selectedSkillIds
.map((skillId) => {
const skill = skillCategory.skills.find(
(s) => s.id === skillId
);
return skill ? { ...skill, id: skillId } : null;
})
.filter(
(skill): skill is NonNullable<typeof skill> => skill !== null
)
.sort((a, b) => {
const importanceOrder = {
incontournable: 2,
majeure: 1,
standard: 0,
};
return (
importanceOrder[b.importance] -
importanceOrder[a.importance]
);
})
.map((skill) => {
const skillEval = categoryEval.skills.find(
(s) => s.skillId === skill.id
);
return (
<SkillProgress
key={skill.id}
skill={skill}
skillEval={skillEval}
/>
);
})}
</div>
) : (
<div className="pt-3 pb-3 text-center">

View File

@@ -13,6 +13,22 @@ export function MentorSection({
userEvaluation,
skillCategories,
}: MentorSectionProps) {
// Fonction de tri par importance
const sortByImportance = (
a: { importance: string },
b: { importance: string }
) => {
const importanceOrder = {
incontournable: 2,
majeure: 1,
standard: 0,
};
return (
importanceOrder[b.importance as keyof typeof importanceOrder] -
importanceOrder[a.importance as keyof typeof importanceOrder]
);
};
// Récupérer les compétences maîtrisées (expert uniquement)
const masteredSkills = userEvaluation.evaluations.flatMap((cat) => {
const skillCategory = skillCategories.find(
@@ -81,6 +97,40 @@ export function MentorSection({
className={`bg-white/5 border border-white/10 rounded-xl p-6 backdrop-blur-sm ${className}`}
>
<div className="space-y-6">
{/* Peut mentorer */}
<div>
<h3 className="text-lg font-semibold text-white mb-3 flex items-center gap-2">
<span className="w-2 h-2 bg-blue-400 rounded-full"></span>
Peut mentorer
</h3>
<div className="flex flex-wrap gap-3">
{mentorSkills.length > 0 ? (
mentorSkills.sort(sortByImportance).map((tech) => {
const colors = getImportanceColors(tech.importance);
return (
<div
key={tech.id}
className={`flex items-center gap-2 px-3 py-2 rounded-lg ${colors.bg} ${colors.border} border transition-all hover:scale-105`}
>
<TechIcon
iconName={tech.icon}
className="w-4 h-4"
fallbackText={tech.name}
/>
<span className={`text-sm font-medium ${colors.text}`}>
{tech.name}
</span>
</div>
);
})
) : (
<p className="text-slate-400 text-sm">
Aucune compétence mentor configurée
</p>
)}
</div>
</div>
{/* Technologies maîtrisées */}
<div>
<h3 className="text-lg font-semibold text-white mb-3 flex items-center gap-2">
@@ -89,7 +139,7 @@ export function MentorSection({
</h3>
<div className="flex flex-wrap gap-3">
{masteredSkills.length > 0 ? (
masteredSkills.map((tech) => {
masteredSkills.sort(sortByImportance).map((tech) => {
const colors = getImportanceColors(tech.importance);
return (
<div
@@ -116,40 +166,6 @@ export function MentorSection({
</div>
</div>
{/* Peut mentorer */}
<div>
<h3 className="text-lg font-semibold text-white mb-3 flex items-center gap-2">
<span className="w-2 h-2 bg-blue-400 rounded-full"></span>
Peut mentorer
</h3>
<div className="flex flex-wrap gap-3">
{mentorSkills.length > 0 ? (
mentorSkills.map((tech) => {
const colors = getImportanceColors(tech.importance);
return (
<div
key={tech.id}
className={`flex items-center gap-2 px-3 py-2 rounded-lg ${colors.bg} ${colors.border} border transition-all hover:scale-105`}
>
<TechIcon
iconName={tech.icon}
className="w-4 h-4"
fallbackText={tech.name}
/>
<span className={`text-sm font-medium ${colors.text}`}>
{tech.name}
</span>
</div>
);
})
) : (
<p className="text-slate-400 text-sm">
Aucune compétence mentor configurée
</p>
)}
</div>
</div>
{/* Technologies à apprendre */}
<div>
<h3 className="text-lg font-semibold text-white mb-3 flex items-center gap-2">
@@ -158,7 +174,7 @@ export function MentorSection({
</h3>
<div className="flex flex-wrap gap-3">
{learningSkills.length > 0 ? (
learningSkills.map((tech) => {
learningSkills.sort(sortByImportance).map((tech) => {
const colors = getImportanceColors(tech.importance);
return (
<div
@@ -178,7 +194,7 @@ export function MentorSection({
})
) : (
<p className="text-slate-400 text-sm">
Aucun objectif d'apprentissage configuré
Aucun objectif d&apos;apprentissage configuré
</p>
)}
</div>

View File

@@ -13,7 +13,7 @@ export function RadarSection({ radarData }: RadarSectionProps) {
<div className="bg-white/5 backdrop-blur-sm border border-white/10 rounded-2xl p-6">
<div className="mb-6">
<h3 className="text-xl font-bold text-white mb-2">
Vue d'ensemble de vos compétences
Vue d&apos;ensemble de vos compétences
</h3>
<p className="text-slate-400 text-sm">
Radar chart représentant votre niveau par catégorie

View File

@@ -1,11 +1,13 @@
import { TechIcon } from "@/components/icons/tech-icon";
import { getSkillLevelLabel } from "@/lib/score-utils";
import { getImportanceColors } from "@/lib/tech-colors";
interface SkillProgressProps {
skill: {
id: string;
name: string;
icon?: string;
importance: "incontournable" | "majeure" | "standard";
};
skillEval?: {
skillId: string;
@@ -14,15 +16,19 @@ interface SkillProgressProps {
}
export function SkillProgress({ skill, skillEval }: SkillProgressProps) {
const colors = getImportanceColors(skill.importance);
return (
<div className="flex items-center justify-between p-2 bg-white/5 border border-white/10 rounded-lg">
<div
className={`flex items-center justify-between p-2 ${colors.bg} border ${colors.border} rounded-lg`}
>
<div className="flex items-center gap-2">
<TechIcon
iconName={skill.icon || ""}
className="w-4 h-4 text-blue-400"
className={`w-4 h-4 ${colors.text}`}
fallbackText={skill.name}
/>
<span className="text-sm text-white">{skill.name}</span>
<span className={`text-sm ${colors.text}`}>{skill.name}</span>
</div>
<div className="flex items-center gap-2">
{skillEval?.level && (

View File

@@ -22,8 +22,8 @@ export function WelcomeScreen() {
Bienvenue ! Commencez votre parcours
</h1>
<p className="text-lg text-slate-400 mb-12 max-w-2xl mx-auto">
Vous êtes connecté avec succès. Il est temps dvaluer vos
compétences techniques pour obtenir une vue d'ensemble personnalisée
Vous êtes connecté avec succès. Il est temps d&apos;évaluer vos
compétences techniques pour obtenir une vue d&apos;ensemble personnalisée
de votre expertise.
</p>
@@ -35,7 +35,7 @@ export function WelcomeScreen() {
Évaluez vos compétences
</h3>
<p className="text-slate-400 text-sm">
Sélectionnez vos domaines d'expertise et évaluez votre niveau
Sélectionnez vos domaines d&apos;expertise et évaluez votre niveau
</p>
</div>
<div className="bg-white/5 border border-white/10 rounded-xl p-6 backdrop-blur-sm">
@@ -63,7 +63,7 @@ export function WelcomeScreen() {
href="/evaluation"
className="group bg-blue-500 hover:bg-blue-600 text-white px-8 py-4 rounded-xl font-medium transition-all inline-flex items-center gap-2"
>
Commencer l'évaluation
Commencer l&apos;évaluation
<ArrowRight className="h-4 w-4 group-hover:translate-x-1 transition-transform" />
</Link>
</div>

View File

@@ -31,19 +31,30 @@ export function AuthWrapper({ teams, initialUser }: AuthWrapperProps) {
setLoading(true);
setError(null);
try {
const response = await authClient.login({ email, password });
const result = await authClient.login({ email, password });
if (!result.success) {
setError(result.error || "Erreur de connexion");
toast({
title: "Erreur de connexion",
description: result.error || "Erreur de connexion",
variant: "destructive",
});
return;
}
toast({
title: "Connexion réussie",
description: response.message,
description: "Bienvenue !",
});
// Rediriger vers l'accueil après connexion
router.push("/");
router.refresh();
} catch (error: any) {
console.error("Login failed:", error);
const errorMessage = error.response?.data?.error || "Erreur de connexion";
const errorMessage = error.message || "Erreur de connexion";
setError(errorMessage);
toast({
@@ -66,26 +77,30 @@ export function AuthWrapper({ teams, initialUser }: AuthWrapperProps) {
setLoading(true);
setError(null);
try {
const response = await authClient.register(data);
const result = await authClient.register(data);
if (!result.success) {
setError(result.error || "Erreur lors de la création du compte");
toast({
title: "Erreur d'inscription",
description: result.error || "Erreur lors de la création du compte",
variant: "destructive",
});
return;
}
toast({
title: "Compte créé",
description: response.message,
});
// Après inscription réussie, connecter automatiquement l'utilisateur
toast({
title: "Connexion automatique",
description: "Redirection vers l'accueil...",
description: "Connexion automatique...",
});
// Rediriger vers l'accueil après inscription réussie
router.push("/");
router.refresh();
} catch (error: any) {
console.error("Register failed:", error);
const errorMessage =
error.response?.data?.error || "Erreur lors de la création du compte";
const errorMessage = error.message || "Erreur lors de la création du compte";
setError(errorMessage);
toast({
@@ -102,11 +117,13 @@ export function AuthWrapper({ teams, initialUser }: AuthWrapperProps) {
try {
await authClient.logout();
// Rediriger vers la page de login après déconnexion
window.location.href = "/login";
router.push("/login");
router.refresh();
} catch (error) {
console.error("Logout failed:", error);
// En cas d'erreur, forcer le rechargement pour nettoyer l'état
window.location.reload();
router.push("/login");
router.refresh();
}
};
@@ -131,7 +148,7 @@ export function AuthWrapper({ teams, initialUser }: AuthWrapperProps) {
onClick={() => (window.location.href = "/")}
className="px-6 py-3 bg-blue-500 hover:bg-blue-600 text-white rounded-lg"
>
Aller à l'accueil
Aller à l&apos;accueil
</button>
<button
onClick={handleLogout}

View File

@@ -298,7 +298,7 @@ export function RegisterForm({
{Object.keys(teamsByDirection).length === 0 && (
<div className="px-3 py-4 text-center text-sm text-slate-400">
Aucune équipe trouvée pour "{searchTerm}"
Aucune équipe trouvée pour &quot;{searchTerm}&quot;
</div>
)}
</div>

View File

@@ -58,7 +58,8 @@ export function SkillEvaluation({
} else if (categories.length > 0 && !selectedCategory) {
setSelectedCategory(categories[0].category);
}
}, [categoryParam, categories]); // Remove selectedCategory from deps to avoid loop
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [categoryParam, categories]); // selectedCategory intentionally excluded to avoid infinite loop
const currentCategory = categories.find(
(cat) => cat.category === selectedCategory

View File

@@ -236,7 +236,7 @@ export function SkillSelector({
<div className="text-center py-8 text-muted-foreground border-2 border-dashed border-muted rounded-lg">
<p className="mb-2">Aucune compétence sélectionnée</p>
<p className="text-sm">
Cliquez sur "Ajouter une compétence" pour commencer
Cliquez sur &quot;Ajouter une compétence&quot; pour commencer
</p>
</div>
)}

View File

@@ -14,6 +14,8 @@ import { TeamMemberProfile, SkillGap } from "@/lib/team-review-types";
import { UserCheck, GraduationCap } from "lucide-react";
import { TechIcon } from "@/components/icons/tech-icon";
import { getImportanceColors } from "@/lib/tech-colors";
import { isCoverageBelowObjective } from "@/lib/evaluation-utils";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
interface SkillMatrixProps {
members: TeamMemberProfile[];
@@ -26,6 +28,21 @@ export function SkillMatrix({ members, skillGaps }: SkillMatrixProps) {
(skill) => skill.skillId && skill.skillName && skill.category
);
// Fonction de tri par importance
const sortByImportance = (a: SkillGap, b: SkillGap) => {
const importanceOrder = {
incontournable: 2,
majeure: 1,
standard: 0,
};
const importanceDiff =
importanceOrder[b.importance] - importanceOrder[a.importance];
if (importanceDiff !== 0) return importanceDiff;
// Si même importance, trier par couverture (ascendant)
return (a.coverage || 0) - (b.coverage || 0);
};
const skillsByCategory = validSkillGaps.reduce((acc, skill) => {
if (!acc[skill.category]) {
acc[skill.category] = [];
@@ -34,6 +51,11 @@ export function SkillMatrix({ members, skillGaps }: SkillMatrixProps) {
return acc;
}, {} as Record<string, SkillGap[]>);
// Trier les compétences par importance dans chaque catégorie
Object.values(skillsByCategory).forEach((skills) => {
skills.sort(sortByImportance);
});
const getLevelBadge = (level: string | null) => {
const colors = {
never: "bg-white/5 text-slate-300",
@@ -80,12 +102,165 @@ export function SkillMatrix({ members, skillGaps }: SkillMatrixProps) {
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-8">
{Object.entries(skillsByCategory).map(([category, skills]) => (
<div key={`category-${category}`}>
<h3 className="text-lg font-semibold text-slate-200 mb-4">
<Tabs defaultValue="all" className="w-full">
<TabsList className="mb-4 bg-white/5 border-white/10">
<TabsTrigger
value="all"
className="data-[state=active]:bg-white/10"
>
Toutes
</TabsTrigger>
{Object.keys(skillsByCategory).map((category) => (
<TabsTrigger
key={category}
value={category}
className="data-[state=active]:bg-white/10"
>
{category}
</h3>
</TabsTrigger>
))}
</TabsList>
<TabsContent value="all">
<div className="rounded-md border border-white/10">
<Table>
<TableHeader>
<TableRow className="border-white/10">
<TableHead className="w-[200px] text-slate-300">
Compétence
</TableHead>
<TableHead className="w-[120px] text-slate-300">
Catégorie
</TableHead>
{members.map((member) => (
<TableHead
key={`header-${member.member.uuid}`}
className="text-slate-300"
>
{member.member.firstName} {member.member.lastName}
</TableHead>
))}
<TableHead className="w-[120px] text-slate-300">
Couverture
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{validSkillGaps.sort(sortByImportance).map((skill) => {
const colors = getImportanceColors(skill.importance);
return (
<TableRow
key={`skill-row-${skill.skillId}-${skill.category}`}
className="border-white/10"
>
<TableCell className="font-medium">
<div className="flex items-center gap-3">
<div
className={`w-8 h-8 rounded-lg ${colors.bg} ${colors.border} border flex items-center justify-center`}
>
<TechIcon
iconName={skill.icon || ""}
className={`w-4 h-4 ${colors.text}`}
fallbackText={skill.skillName}
/>
</div>
<div className="flex flex-col gap-1">
<span className={`${colors.text} font-medium`}>
{skill.skillName}
</span>
{skill.risk === "high" && (
<Badge
variant="destructive"
className="bg-red-500/20 text-red-200 border-red-500/30 w-fit"
>
Risque
</Badge>
)}
</div>
</div>
</TableCell>
<TableCell>
<Badge
variant="outline"
className="bg-white/5 text-slate-300 border-white/10"
>
{skill.category}
</Badge>
</TableCell>
{members.map((member) => {
const memberSkill = member.skills.find(
(s) => s.skillId === skill.skillId
);
return (
<TableCell
key={`skill-${skill.skillId}-member-${member.member.uuid}`}
>
<div className="flex items-center gap-2 flex-wrap">
{getLevelBadge(memberSkill?.level || null)}
{memberSkill?.canMentor && (
<Badge
variant="outline"
className="flex items-center gap-1 bg-green-500/10 text-green-200 border-green-500/30"
>
<UserCheck className="h-3 w-3" />
<span className="text-xs">Mentor</span>
</Badge>
)}
{memberSkill?.wantsToLearn && (
<Badge
variant="outline"
className="flex items-center gap-1 bg-blue-500/10 text-blue-200 border-blue-500/30"
>
<GraduationCap className="h-3 w-3" />
<span className="text-xs">Apprenant</span>
</Badge>
)}
</div>
</TableCell>
);
})}
<TableCell>
<div className="flex items-center gap-2">
<div className="w-full bg-white/10 rounded-full h-2">
<div
className={`h-2 rounded-full ${
(skill.coverage || 0) >= 75
? "bg-green-500/50"
: (skill.coverage || 0) >= 50
? "bg-yellow-500/50"
: "bg-red-500/50"
}`}
style={{
width: `${Math.max(
0,
Math.min(100, skill.coverage || 0)
)}%`,
}}
/>
</div>
<span
className={`text-sm whitespace-nowrap ${
(skill.coverage || 0) >= 75
? "text-green-400"
: (skill.coverage || 0) >= 50
? "text-yellow-400"
: "text-red-400"
}`}
>
{Math.round(skill.coverage || 0)}%
</span>
</div>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
</TabsContent>
{Object.entries(skillsByCategory).map(([category, skills]) => (
<TabsContent key={category} value={category}>
<div className="rounded-md border border-white/10">
<Table>
<TableHeader>
@@ -176,10 +351,13 @@ export function SkillMatrix({ members, skillGaps }: SkillMatrixProps) {
<div className="flex items-center gap-2">
<div className="w-full bg-white/10 rounded-full h-2">
<div
className={`h-2 rounded-full ${colors.bg.replace(
"/20",
"/50"
)}`}
className={`h-2 rounded-full ${
(skill.coverage || 0) >= 75
? "bg-green-500/50"
: (skill.coverage || 0) >= 50
? "bg-yellow-500/50"
: "bg-red-500/50"
}`}
style={{
width: `${Math.max(
0,
@@ -189,7 +367,13 @@ export function SkillMatrix({ members, skillGaps }: SkillMatrixProps) {
/>
</div>
<span
className={`text-sm ${colors.text} whitespace-nowrap`}
className={`text-sm whitespace-nowrap ${
(skill.coverage || 0) >= 75
? "text-green-400"
: (skill.coverage || 0) >= 50
? "text-yellow-400"
: "text-red-400"
}`}
>
{Math.round(skill.coverage || 0)}%
</span>
@@ -201,9 +385,9 @@ export function SkillMatrix({ members, skillGaps }: SkillMatrixProps) {
</TableBody>
</Table>
</div>
</div>
</TabsContent>
))}
</div>
</Tabs>
</CardContent>
</Card>
);

View File

@@ -6,6 +6,10 @@ import { Progress } from "@/components/ui/progress";
import { Badge } from "@/components/ui/badge";
import { AlertTriangle } from "lucide-react";
import { getImportanceColors } from "@/lib/tech-colors";
import {
COVERAGE_OBJECTIVES,
isCoverageBelowObjective,
} from "@/lib/evaluation-utils";
import {
Tooltip,
TooltipContent,
@@ -41,20 +45,20 @@ export function TeamOverview({
const categoriesNeedingAttention = [...categoryCoverage]
.map((cat) => {
// Pour chaque catégorie, on identifie :
// 1. Les compétences incontournables sous-couvertes (< 75%)
// 1. Les compétences incontournables sous-couvertes
const uncoveredIncontournables = skillGaps.filter(
(gap) =>
gap.category === cat.category &&
gap.importance === "incontournable" &&
gap.coverage < 75
isCoverageBelowObjective(gap.coverage, gap.importance)
);
// 2. Les compétences majeures sous-couvertes (< 60%)
// 2. Les compétences majeures sous-couvertes
const uncoveredMajeures = skillGaps.filter(
(gap) =>
gap.category === cat.category &&
gap.importance === "majeure" &&
gap.coverage < 60
isCoverageBelowObjective(gap.coverage, gap.importance)
);
// Une catégorie nécessite de l'attention si :
@@ -81,14 +85,14 @@ export function TeamOverview({
attentionScore,
};
})
.filter(Boolean)
.filter((cat): cat is NonNullable<typeof cat> => cat !== null)
.sort((a, b) => b.attentionScore - a.attentionScore)
.slice(0, 3);
return (
<Card className="bg-white/5 border-white/10 backdrop-blur">
<CardHeader>
<CardTitle className="text-slate-200">Vue d'ensemble</CardTitle>
<CardTitle className="text-slate-200">Vue d&apos;ensemble</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
@@ -190,8 +194,7 @@ export function TeamOverview({
})
.map((skill) => {
const colors = getImportanceColors(skill.importance);
const target =
skill.importance === "incontournable" ? 75 : 60;
const target = COVERAGE_OBJECTIVES[skill.importance];
return (
<Tooltip key={skill.skillId}>
<TooltipTrigger>
@@ -203,7 +206,10 @@ export function TeamOverview({
</span>
<span
className={
skill.coverage < target
isCoverageBelowObjective(
skill.coverage,
skill.importance
)
? "text-red-400"
: "text-slate-400"
}
@@ -224,7 +230,10 @@ export function TeamOverview({
<br />
Actuel : {skill.coverage.toFixed(0)}%
<br />
{skill.coverage < target
{isCoverageBelowObjective(
skill.coverage,
skill.importance
)
? `Manque ${(
target - skill.coverage
).toFixed(0)}%`

View File

@@ -15,6 +15,10 @@ import {
Cell,
Legend,
} from "recharts";
import {
COVERAGE_OBJECTIVES,
isCoverageBelowObjective,
} from "@/lib/evaluation-utils";
import {
Tooltip as UITooltip,
TooltipContent,
@@ -82,9 +86,11 @@ export function TeamStats({
// Gaps critiques par catégorie, séparés par importance
const criticalGapsByCategory = skillGaps.reduce((acc, gap) => {
const isIncontournableUndercovered =
gap.importance === "incontournable" && gap.coverage < 75;
gap.importance === "incontournable" &&
isCoverageBelowObjective(gap.coverage, gap.importance);
const isMajeureUndercovered =
gap.importance === "majeure" && gap.coverage < 60;
gap.importance === "majeure" &&
isCoverageBelowObjective(gap.coverage, gap.importance);
if (isIncontournableUndercovered || isMajeureUndercovered) {
if (!acc[gap.category]) {
@@ -124,7 +130,7 @@ export function TeamStats({
<Card className="bg-white/5 border-white/10 backdrop-blur">
<CardHeader>
<CardTitle className="text-slate-200">
Statistiques de lquipe
Statistiques de l&apos;équipe
</CardTitle>
</CardHeader>
<CardContent className="space-y-8">
@@ -161,8 +167,13 @@ export function TeamStats({
<div className="text-xs space-y-1">
<p>Compétences critiques sous-couvertes :</p>
<ul className="list-disc list-inside space-y-0.5">
<li>Incontournables : couverture &lt; 75%</li>
<li>Majeures : couverture &lt; 60%</li>
<li>
Incontournables : couverture &lt;{" "}
{COVERAGE_OBJECTIVES.incontournable}%
</li>
<li>
Majeures : couverture &lt; {COVERAGE_OBJECTIVES.majeure}%
</li>
</ul>
</div>
</TooltipContent>
@@ -205,7 +216,10 @@ export function TeamStats({
<TooltipContent className="bg-slate-900 text-slate-200 border border-slate-700">
<div className="text-xs">
<p>Compétences incontournables</p>
<p className="text-slate-400">Objectif : 75% de couverture</p>
<p className="text-slate-400">
Objectif : {COVERAGE_OBJECTIVES.incontournable}% de
couverture
</p>
</div>
</TooltipContent>
</UITooltip>
@@ -343,16 +357,16 @@ export function TeamStats({
formatter={(value, name) => {
const label =
name === "incontournable"
? "Incontournables (obj. 75%)"
: "Majeures (obj. 60%)";
? `Incontournables (obj. ${COVERAGE_OBJECTIVES.incontournable}%)`
: `Majeures (obj. ${COVERAGE_OBJECTIVES.majeure}%)`;
return [value, label];
}}
/>
<Legend
formatter={(value) => {
return value === "incontournable"
? "Incontournables (obj. 75%)"
: "Majeures (obj. 60%)";
? `Incontournables (obj. ${COVERAGE_OBJECTIVES.incontournable}%)`
: `Majeures (obj. ${COVERAGE_OBJECTIVES.majeure}%)`;
}}
wrapperStyle={{
paddingTop: "20px",

View File

@@ -1,24 +1,30 @@
version: "3.8"
services:
app:
peakskills-app:
build: .
ports:
- "3000:3000"
- "3008:3000"
environment:
NODE_ENV: production
DB_HOST: postgres
DB_HOST: peakskills-postgres
DB_PORT: 5432
DB_NAME: peakskills
DB_USER: peakskills_user
DB_PASSWORD: peakskills_password
NEXT_PUBLIC_API_URL: ""
# Auth.js configuration
AUTH_SECRET: ${AUTH_SECRET:-FvhDat3sJK5TI1L4fcugCGFmLsO1BCi+mwSYeLkl8JA=}
AUTH_TRUST_HOST: "true"
AUTH_URL: ${AUTH_URL:-http://localhost:3008}
depends_on:
postgres:
peakskills-postgres:
condition: service_healthy
restart: unless-stopped
labels:
- "com.centurylinklabs.watchtower.enable=false"
postgres:
peakskills-postgres:
image: postgres:15
environment:
POSTGRES_DB: peakskills
@@ -28,7 +34,7 @@ services:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./scripts/init.sql:/docker-entrypoint-initdb.d/init.sql
- ${INIT_SQL:-./scripts/init.sql}:/docker-entrypoint-initdb.d/init.sql
healthcheck:
test: ["CMD-SHELL", "pg_isready -U peakskills_user -d peakskills"]
interval: 10s
@@ -36,16 +42,16 @@ services:
retries: 5
# Adminer - Interface web pour PostgreSQL
adminer:
image: adminer:4.8.1
restart: always
ports:
- "8080:8080"
environment:
ADMINER_DEFAULT_SERVER: postgres
ADMINER_DESIGN: pepa-linha-dark
depends_on:
- postgres
# peakskills-adminer:
# image: adminer:4.8.1
# restart: always
# ports:
# - "8080:8080"
# environment:
# ADMINER_DEFAULT_SERVER: postgres
# ADMINER_DESIGN: pepa-linha-dark
# depends_on:
# - peakskills-postgres
volumes:
postgres_data:

View File

@@ -61,7 +61,7 @@ export function useTeamsManagement(
setTeams((prev) => [...prev, newLocalTeam]);
// Ajouter les stats de la nouvelle équipe (avec les propriétés minimales)
const newTeamStats = {
const newTeamStats: TeamStats = {
teamId: newTeam.id,
teamName: newTeam.name,
direction: newTeam.direction,
@@ -70,7 +70,11 @@ export function useTeamsManagement(
skillCoverage: 0,
topSkills: [],
members: [],
} as TeamStats;
criticalSkillsCoverage: {
incontournable: 0,
majeure: 0,
},
};
setTeamStats((prev) => [...prev, newTeamStats]);
return true;
} catch (error: any) {

View File

@@ -3,6 +3,7 @@ import {
CategoryEvaluation,
RadarChartData,
SkillCategory,
SkillImportance,
} from "./types";
export function calculateCategoryScore(
@@ -51,3 +52,47 @@ export function createEmptyEvaluation(
selectedSkillIds: [],
}));
}
export const COVERAGE_OBJECTIVES: Record<SkillImportance, number> = {
incontournable: 75,
majeure: 60,
standard: 0,
};
export function isCoverageBelowObjective(
coverage: number,
importance: SkillImportance
): boolean {
const objective = COVERAGE_OBJECTIVES[importance];
return objective > 0 && coverage < objective;
}
export function getCoverageObjective(importance: SkillImportance): number {
return COVERAGE_OBJECTIVES[importance];
}
export function calculateSkillCoverage(
levels: number[],
totalMembers: number
): number {
if (levels.length === 0 || totalMembers === 0) return 0;
// Compter le nombre de membres autonomes ou experts (niveau >= 2)
const expertCount = levels.filter((level) => level >= 2).length;
// La couverture est le pourcentage de membres autonomes ou experts
return (expertCount / totalMembers) * 100;
}
export function generateSkillCoverageSQL(levelField: string): string {
return `
COALESCE(
(COUNT(*) FILTER (WHERE ${levelField} >= 2) * 100.0) / NULLIF(COUNT(*), 0),
0
)
`;
}
export function isExpertLevel(level: number): boolean {
return level >= 2;
}

View File

@@ -19,13 +19,15 @@ export const SKILL_LEVEL_VALUES: Record<Exclude<SkillLevel, null>, number> = {
expert: 3,
};
export type SkillImportance = "incontournable" | "majeure" | "standard";
export interface Skill {
id: string;
name: string;
description: string;
icon?: string;
links: string[];
importance?: string;
importance: SkillImportance;
}
export interface SkillCategory {

View File

@@ -1,44 +1,28 @@
import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/auth";
import { NextResponse } from "next/server";
const COOKIE_NAME = "session_token";
export default auth((req) => {
const { pathname } = req.nextUrl;
const isLoggedIn = !!req.auth;
const isOnLoginPage = pathname === "/login";
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Pages qui ne nécessitent pas d'authentification
const publicPaths = ["/login"];
// Pages API qui ne nécessitent pas d'authentification
const publicApiPaths = ["/api/teams", "/api/auth"];
console.log(pathname);
// Vérifier si c'est une route publique
if (
publicPaths.includes(pathname) ||
publicApiPaths.some((path) => pathname.startsWith(path))
) {
// Pages publiques (API auth et teams)
if (pathname.startsWith("/api/auth") || pathname.startsWith("/api/teams")) {
return NextResponse.next();
}
// Vérifier si c'est un fichier statique
if (
pathname.includes("/_next/") ||
pathname.includes("/favicon.ico") ||
pathname.includes("/public/")
) {
return NextResponse.next();
// Si connecté et sur login, rediriger vers home
if (isLoggedIn && isOnLoginPage) {
return NextResponse.redirect(new URL("/", req.url));
}
// Vérifier le cookie d'authentification (maintenant un UUID)
const userUuid = request.cookies.get(COOKIE_NAME)?.value;
if (!userUuid) {
// Rediriger vers la page de login si pas authentifié
const loginUrl = new URL("/login", request.url);
return NextResponse.redirect(loginUrl);
// Si non connecté et pas sur login, rediriger vers login
if (!isLoggedIn && !isOnLoginPage) {
return NextResponse.redirect(new URL("/login", req.url));
}
return NextResponse.next();
}
});
export const config = {
matcher: [

View File

@@ -6,6 +6,7 @@
"build": "next build",
"dev": "next dev --turbopack",
"lint": "next lint",
"lint:fix": "next lint --fix",
"start": "next start",
"generate-test-data": "tsx scripts/generate-test-data.ts",
"sync-skills": "tsx scripts/sync-skills.ts",
@@ -60,7 +61,8 @@
"geist": "^1.3.1",
"input-otp": "1.4.1",
"lucide-react": "^0.454.0",
"next": "15.2.4",
"next": "15.5.9",
"next-auth": "5.0.0-beta.29",
"next-themes": "^0.4.6",
"pg": "^8.12.0",
"react": "^19",

504
pnpm-lock.yaml generated
View File

@@ -139,7 +139,7 @@ importers:
version: 8.5.1(react@19.1.1)
geist:
specifier: ^1.3.1
version: 1.4.2(next@15.2.4(react-dom@19.1.1(react@19.1.1))(react@19.1.1))
version: 1.4.2(next@15.5.9(react-dom@19.1.1(react@19.1.1))(react@19.1.1))
input-otp:
specifier: 1.4.1
version: 1.4.1(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
@@ -147,8 +147,11 @@ importers:
specifier: ^0.454.0
version: 0.454.0(react@19.1.1)
next:
specifier: 15.2.4
version: 15.2.4(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
specifier: 15.5.9
version: 15.5.9(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
next-auth:
specifier: 5.0.0-beta.29
version: 5.0.0-beta.29(next@15.5.9(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(react@19.1.1)
next-themes:
specifier: ^0.4.6
version: 0.4.6(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
@@ -223,6 +226,20 @@ packages:
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
engines: {node: '>=10'}
'@auth/core@0.40.0':
resolution: {integrity: sha512-n53uJE0RH5SqZ7N1xZoMKekbHfQgjd0sAEyUbE+IYJnmuQkbvuZnXItCU7d+i7Fj8VGOgqvNO7Mw4YfBTlZeQw==}
peerDependencies:
'@simplewebauthn/browser': ^9.0.1
'@simplewebauthn/server': ^9.0.2
nodemailer: ^6.8.0
peerDependenciesMeta:
'@simplewebauthn/browser':
optional: true
'@simplewebauthn/server':
optional: true
nodemailer:
optional: true
'@babel/runtime@7.28.3':
resolution: {integrity: sha512-9uIQ10o0WGdpP6GDhXcdOJPJuDgFtIDtN/9+ArJQ2NAfAmiuhTQdzkaTGR33v43GYS2UrSA0eX2pPPHoFVvpxA==}
engines: {node: '>=6.9.0'}
@@ -230,8 +247,8 @@ packages:
'@date-fns/tz@1.2.0':
resolution: {integrity: sha512-LBrd7MiJZ9McsOgxqWX7AaxrDjcFVjWH/tIKJd7pnR7McaslGYOP1QmmiBXdJH/H/yLCT+rcQ7FaPBUxRGUtrg==}
'@emnapi/runtime@1.4.5':
resolution: {integrity: sha512-++LApOtY0pEEz1zrd9vy1/zXVaVJJ/EbAF3u0fXIzPJEDtnITsBGbbK0EkM72amhl/R5b+5xx0Y/QhcVOpuulg==}
'@emnapi/runtime@1.7.1':
resolution: {integrity: sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==}
'@esbuild/aix-ppc64@0.25.9':
resolution: {integrity: sha512-OaGtL73Jck6pBKjNIe24BnFE6agGl+6KxDtTfHhy1HmhthfKouEcOhqpSL64K4/0WCtbKFLOdzD/44cJ4k9opA==}
@@ -431,107 +448,139 @@ packages:
peerDependencies:
react-hook-form: ^7.0.0
'@img/sharp-darwin-arm64@0.33.5':
resolution: {integrity: sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==}
'@img/colour@1.0.0':
resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==}
engines: {node: '>=18'}
'@img/sharp-darwin-arm64@0.34.5':
resolution: {integrity: sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [darwin]
'@img/sharp-darwin-x64@0.33.5':
resolution: {integrity: sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==}
'@img/sharp-darwin-x64@0.34.5':
resolution: {integrity: sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-darwin-arm64@1.0.4':
resolution: {integrity: sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==}
'@img/sharp-libvips-darwin-arm64@1.2.4':
resolution: {integrity: sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==}
cpu: [arm64]
os: [darwin]
'@img/sharp-libvips-darwin-x64@1.0.4':
resolution: {integrity: sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==}
'@img/sharp-libvips-darwin-x64@1.2.4':
resolution: {integrity: sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==}
cpu: [x64]
os: [darwin]
'@img/sharp-libvips-linux-arm64@1.0.4':
resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==}
'@img/sharp-libvips-linux-arm64@1.2.4':
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
cpu: [arm64]
os: [linux]
'@img/sharp-libvips-linux-arm@1.0.5':
resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==}
'@img/sharp-libvips-linux-arm@1.2.4':
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
cpu: [arm]
os: [linux]
'@img/sharp-libvips-linux-s390x@1.0.4':
resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==}
'@img/sharp-libvips-linux-ppc64@1.2.4':
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
cpu: [ppc64]
os: [linux]
'@img/sharp-libvips-linux-riscv64@1.2.4':
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
cpu: [riscv64]
os: [linux]
'@img/sharp-libvips-linux-s390x@1.2.4':
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
cpu: [s390x]
os: [linux]
'@img/sharp-libvips-linux-x64@1.0.4':
resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==}
'@img/sharp-libvips-linux-x64@1.2.4':
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
cpu: [x64]
os: [linux]
'@img/sharp-libvips-linuxmusl-arm64@1.0.4':
resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==}
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
cpu: [arm64]
os: [linux]
'@img/sharp-libvips-linuxmusl-x64@1.0.4':
resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==}
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
cpu: [x64]
os: [linux]
'@img/sharp-linux-arm64@0.33.5':
resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==}
'@img/sharp-linux-arm64@0.34.5':
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
'@img/sharp-linux-arm@0.33.5':
resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==}
'@img/sharp-linux-arm@0.34.5':
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
'@img/sharp-linux-s390x@0.33.5':
resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==}
'@img/sharp-linux-ppc64@0.34.5':
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ppc64]
os: [linux]
'@img/sharp-linux-riscv64@0.34.5':
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [riscv64]
os: [linux]
'@img/sharp-linux-s390x@0.34.5':
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
'@img/sharp-linux-x64@0.33.5':
resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==}
'@img/sharp-linux-x64@0.34.5':
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
'@img/sharp-linuxmusl-arm64@0.33.5':
resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==}
'@img/sharp-linuxmusl-arm64@0.34.5':
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
'@img/sharp-linuxmusl-x64@0.33.5':
resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==}
'@img/sharp-linuxmusl-x64@0.34.5':
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
'@img/sharp-wasm32@0.33.5':
resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==}
'@img/sharp-wasm32@0.34.5':
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [wasm32]
'@img/sharp-win32-ia32@0.33.5':
resolution: {integrity: sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==}
'@img/sharp-win32-arm64@0.34.5':
resolution: {integrity: sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [win32]
'@img/sharp-win32-ia32@0.34.5':
resolution: {integrity: sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ia32]
os: [win32]
'@img/sharp-win32-x64@0.33.5':
resolution: {integrity: sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==}
'@img/sharp-win32-x64@0.34.5':
resolution: {integrity: sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [win32]
@@ -556,57 +605,60 @@ packages:
'@jridgewell/trace-mapping@0.3.30':
resolution: {integrity: sha512-GQ7Nw5G2lTu/BtHTKfXhKHok2WGetd4XYcVKGx00SjAk8GMwgJM3zr6zORiPGuOE+/vkc90KtTosSSvaCjKb2Q==}
'@next/env@15.2.4':
resolution: {integrity: sha512-+SFtMgoiYP3WoSswuNmxJOCwi06TdWE733D+WPjpXIe4LXGULwEaofiiAy6kbS0+XjM5xF5n3lKuBwN2SnqD9g==}
'@next/env@15.5.9':
resolution: {integrity: sha512-4GlTZ+EJM7WaW2HEZcyU317tIQDjkQIyENDLxYJfSWlfqguN+dHkZgyQTV/7ykvobU7yEH5gKvreNrH4B6QgIg==}
'@next/swc-darwin-arm64@15.2.4':
resolution: {integrity: sha512-1AnMfs655ipJEDC/FHkSr0r3lXBgpqKo4K1kiwfUf3iE68rDFXZ1TtHdMvf7D0hMItgDZ7Vuq3JgNMbt/+3bYw==}
'@next/swc-darwin-arm64@15.5.7':
resolution: {integrity: sha512-IZwtxCEpI91HVU/rAUOOobWSZv4P2DeTtNaCdHqLcTJU4wdNXgAySvKa/qJCgR5m6KI8UsKDXtO2B31jcaw1Yw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@next/swc-darwin-x64@15.2.4':
resolution: {integrity: sha512-3qK2zb5EwCwxnO2HeO+TRqCubeI/NgCe+kL5dTJlPldV/uwCnUgC7VbEzgmxbfrkbjehL4H9BPztWOEtsoMwew==}
'@next/swc-darwin-x64@15.5.7':
resolution: {integrity: sha512-UP6CaDBcqaCBuiq/gfCEJw7sPEoX1aIjZHnBWN9v9qYHQdMKvCKcAVs4OX1vIjeE+tC5EIuwDTVIoXpUes29lg==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@next/swc-linux-arm64-gnu@15.2.4':
resolution: {integrity: sha512-HFN6GKUcrTWvem8AZN7tT95zPb0GUGv9v0d0iyuTb303vbXkkbHDp/DxufB04jNVD+IN9yHy7y/6Mqq0h0YVaQ==}
'@next/swc-linux-arm64-gnu@15.5.7':
resolution: {integrity: sha512-NCslw3GrNIw7OgmRBxHtdWFQYhexoUCq+0oS2ccjyYLtcn1SzGzeM54jpTFonIMUjNbHmpKpziXnpxhSWLcmBA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@next/swc-linux-arm64-musl@15.2.4':
resolution: {integrity: sha512-Oioa0SORWLwi35/kVB8aCk5Uq+5/ZIumMK1kJV+jSdazFm2NzPDztsefzdmzzpx5oGCJ6FkUC7vkaUseNTStNA==}
'@next/swc-linux-arm64-musl@15.5.7':
resolution: {integrity: sha512-nfymt+SE5cvtTrG9u1wdoxBr9bVB7mtKTcj0ltRn6gkP/2Nu1zM5ei8rwP9qKQP0Y//umK+TtkKgNtfboBxRrw==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
'@next/swc-linux-x64-gnu@15.2.4':
resolution: {integrity: sha512-yb5WTRaHdkgOqFOZiu6rHV1fAEK0flVpaIN2HB6kxHVSy/dIajWbThS7qON3W9/SNOH2JWkVCyulgGYekMePuw==}
'@next/swc-linux-x64-gnu@15.5.7':
resolution: {integrity: sha512-hvXcZvCaaEbCZcVzcY7E1uXN9xWZfFvkNHwbe/n4OkRhFWrs1J1QV+4U1BN06tXLdaS4DazEGXwgqnu/VMcmqw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@next/swc-linux-x64-musl@15.2.4':
resolution: {integrity: sha512-Dcdv/ix6srhkM25fgXiyOieFUkz+fOYkHlydWCtB0xMST6X9XYI3yPDKBZt1xuhOytONsIFJFB08xXYsxUwJLw==}
'@next/swc-linux-x64-musl@15.5.7':
resolution: {integrity: sha512-4IUO539b8FmF0odY6/SqANJdgwn1xs1GkPO5doZugwZ3ETF6JUdckk7RGmsfSf7ws8Qb2YB5It33mvNL/0acqA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
'@next/swc-win32-arm64-msvc@15.2.4':
resolution: {integrity: sha512-dW0i7eukvDxtIhCYkMrZNQfNicPDExt2jPb9AZPpL7cfyUo7QSNl1DjsHjmmKp6qNAqUESyT8YFl/Aw91cNJJg==}
'@next/swc-win32-arm64-msvc@15.5.7':
resolution: {integrity: sha512-CpJVTkYI3ZajQkC5vajM7/ApKJUOlm6uP4BknM3XKvJ7VXAvCqSjSLmM0LKdYzn6nBJVSjdclx8nYJSa3xlTgQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@next/swc-win32-x64-msvc@15.2.4':
resolution: {integrity: sha512-SbnWkJmkS7Xl3kre8SdMF6F/XDh1DTFEhp0jRTj/uB8iPKoU2bb2NDfcu+iifv1+mxQEd1g2vvSxcZbXSKyWiQ==}
'@next/swc-win32-x64-msvc@15.5.7':
resolution: {integrity: sha512-gMzgBX164I6DN+9/PGA+9dQiwmTkE4TloBNx8Kv9UiGARsr9Nba7IpcBRA1iTV9vwlYnrE3Uy6I7Aj6qLjQuqw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@panva/hkdf@1.2.1':
resolution: {integrity: sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw==}
'@radix-ui/number@1.1.0':
resolution: {integrity: sha512-V3gRzhVNU1ldS5XhAPTom1fOIo4ccrjjJgmE+LI2h/WaFpHmx0MQApT+KZHnx8abG6Avtfcz4WoEciMnpFT3HQ==}
@@ -1263,9 +1315,6 @@ packages:
'@radix-ui/rect@1.1.0':
resolution: {integrity: sha512-A9+lCBZoaMJlVKcRBz2YByCG+Cp2t6nAnMnNba+XiWxnj6r4JUFqfsgwocMBZU9LPtdxC6wB56ySYpc7LQIoJg==}
'@swc/counter@0.1.3':
resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==}
'@swc/helpers@0.5.15':
resolution: {integrity: sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==}
@@ -1429,13 +1478,12 @@ packages:
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
hasBin: true
busboy@1.6.0:
resolution: {integrity: sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==}
engines: {node: '>=10.16.0'}
caniuse-lite@1.0.30001735:
resolution: {integrity: sha512-EV/laoX7Wq2J9TQlyIXRxTJqIw4sxfXS4OYgudGxBYRuTv0q7AM6yMEpU/Vo1I94thg9U6EZ2NfZx9GJq83u7w==}
caniuse-lite@1.0.30001760:
resolution: {integrity: sha512-7AAMPcueWELt1p3mi13HR/LHH0TJLT11cnwDJEs3xA4+CK/PLKeO9Kl1oru24htkyUKtkGCvAx4ohB0Ttry8Dw==}
chownr@3.0.0:
resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==}
engines: {node: '>=18'}
@@ -1456,20 +1504,6 @@ packages:
react: ^18 || ^19 || ^19.0.0-rc
react-dom: ^18 || ^19 || ^19.0.0-rc
color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
color-string@1.9.1:
resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==}
color@4.2.3:
resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==}
engines: {node: '>=12.5.0'}
csstype@3.1.3:
resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==}
@@ -1530,6 +1564,10 @@ packages:
resolution: {integrity: sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==}
engines: {node: '>=8'}
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
detect-node-es@1.1.0:
resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==}
@@ -1605,13 +1643,13 @@ packages:
resolution: {integrity: sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==}
engines: {node: '>=12'}
is-arrayish@0.3.2:
resolution: {integrity: sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==}
jiti@2.5.1:
resolution: {integrity: sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==}
hasBin: true
jose@6.1.0:
resolution: {integrity: sha512-TTQJyoEoKcC1lscpVDCSsVgYzUDg/0Bt3WE//WiTPK6uOCQC2KZS4MpugbMWt/zyjkopgZoXhZuCi00gLudfUA==}
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -1712,19 +1750,35 @@ packages:
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
next-auth@5.0.0-beta.29:
resolution: {integrity: sha512-Ukpnuk3NMc/LiOl32njZPySk7pABEzbjhMUFd5/n10I0ZNC7NCuVv8IY2JgbDek2t/PUOifQEoUiOOTLy4os5A==}
peerDependencies:
'@simplewebauthn/browser': ^9.0.1
'@simplewebauthn/server': ^9.0.2
next: ^14.0.0-0 || ^15.0.0-0
nodemailer: ^6.6.5
react: ^18.2.0 || ^19.0.0-0
peerDependenciesMeta:
'@simplewebauthn/browser':
optional: true
'@simplewebauthn/server':
optional: true
nodemailer:
optional: true
next-themes@0.4.6:
resolution: {integrity: sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA==}
peerDependencies:
react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc
next@15.2.4:
resolution: {integrity: sha512-VwL+LAaPSxEkd3lU2xWbgEOtrM8oedmyhBqaVNmgKB+GvZlCy9rgaEc+y2on0wv+l0oSFqLtYD6dcC1eAedUaQ==}
next@15.5.9:
resolution: {integrity: sha512-agNLK89seZEtC5zUHwtut0+tNrc0Xw4FT/Dg+B/VLEo9pAcS9rtTKpek3V6kVcVwsB2YlqMaHdfZL4eLEVYuCg==}
engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0}
hasBin: true
peerDependencies:
'@opentelemetry/api': ^1.1.0
'@playwright/test': ^1.41.2
'@playwright/test': ^1.51.1
babel-plugin-react-compiler: '*'
react: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0
react-dom: ^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0
@@ -1754,6 +1808,9 @@ packages:
resolution: {integrity: sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA==}
engines: {node: '>=0.10.0'}
oauth4webapi@3.8.2:
resolution: {integrity: sha512-FzZZ+bht5X0FKe7Mwz3DAVAmlH1BV5blSak/lHMBKz0/EBMhX6B10GlQYI51+oRp8ObJaX0g6pXrAxZh5s8rjw==}
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
@@ -1822,6 +1879,14 @@ packages:
resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==}
engines: {node: '>=0.10.0'}
preact-render-to-string@6.5.11:
resolution: {integrity: sha512-ubnauqoGczeGISiOh6RjX0/cdaF8v/oDXIjO85XALCQjwQP+SB4RDXXtvZ6yTYSjG+PC1QRP2AhPgCEsM2EvUw==}
peerDependencies:
preact: '>=10'
preact@10.24.3:
resolution: {integrity: sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==}
prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
@@ -1916,18 +1981,15 @@ packages:
scheduler@0.26.0:
resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==}
semver@7.7.2:
resolution: {integrity: sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==}
semver@7.7.3:
resolution: {integrity: sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==}
engines: {node: '>=10'}
hasBin: true
sharp@0.33.5:
resolution: {integrity: sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==}
sharp@0.34.5:
resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
simple-swizzle@0.2.2:
resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==}
sonner@1.7.4:
resolution: {integrity: sha512-DIS8z4PfJRbIyfVFDVnK9rO3eYDtse4Omcm6bt0oEr5/jtLgysmjuBl1frJ9E/EQZrFmKx2A8m/s5s9CRXIzhw==}
peerDependencies:
@@ -1942,10 +2004,6 @@ packages:
resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==}
engines: {node: '>= 10.x'}
streamsearch@1.1.0:
resolution: {integrity: sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==}
engines: {node: '>=10.0.0'}
styled-jsx@5.1.6:
resolution: {integrity: sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==}
engines: {node: '>= 12.0.0'}
@@ -2055,11 +2113,19 @@ snapshots:
'@alloc/quick-lru@5.2.0': {}
'@auth/core@0.40.0':
dependencies:
'@panva/hkdf': 1.2.1
jose: 6.1.0
oauth4webapi: 3.8.2
preact: 10.24.3
preact-render-to-string: 6.5.11(preact@10.24.3)
'@babel/runtime@7.28.3': {}
'@date-fns/tz@1.2.0': {}
'@emnapi/runtime@1.4.5':
'@emnapi/runtime@1.7.1':
dependencies:
tslib: 2.8.1
optional: true
@@ -2183,79 +2249,101 @@ snapshots:
dependencies:
react-hook-form: 7.62.0(react@19.1.1)
'@img/sharp-darwin-arm64@0.33.5':
'@img/colour@1.0.0':
optional: true
'@img/sharp-darwin-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-darwin-arm64': 1.0.4
'@img/sharp-libvips-darwin-arm64': 1.2.4
optional: true
'@img/sharp-darwin-x64@0.33.5':
'@img/sharp-darwin-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-darwin-x64': 1.0.4
'@img/sharp-libvips-darwin-x64': 1.2.4
optional: true
'@img/sharp-libvips-darwin-arm64@1.0.4':
'@img/sharp-libvips-darwin-arm64@1.2.4':
optional: true
'@img/sharp-libvips-darwin-x64@1.0.4':
'@img/sharp-libvips-darwin-x64@1.2.4':
optional: true
'@img/sharp-libvips-linux-arm64@1.0.4':
'@img/sharp-libvips-linux-arm64@1.2.4':
optional: true
'@img/sharp-libvips-linux-arm@1.0.5':
'@img/sharp-libvips-linux-arm@1.2.4':
optional: true
'@img/sharp-libvips-linux-s390x@1.0.4':
'@img/sharp-libvips-linux-ppc64@1.2.4':
optional: true
'@img/sharp-libvips-linux-x64@1.0.4':
'@img/sharp-libvips-linux-riscv64@1.2.4':
optional: true
'@img/sharp-libvips-linuxmusl-arm64@1.0.4':
'@img/sharp-libvips-linux-s390x@1.2.4':
optional: true
'@img/sharp-libvips-linuxmusl-x64@1.0.4':
'@img/sharp-libvips-linux-x64@1.2.4':
optional: true
'@img/sharp-linux-arm64@0.33.5':
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
optional: true
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
optional: true
'@img/sharp-linux-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-arm64': 1.0.4
'@img/sharp-libvips-linux-arm64': 1.2.4
optional: true
'@img/sharp-linux-arm@0.33.5':
'@img/sharp-linux-arm@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-arm': 1.0.5
'@img/sharp-libvips-linux-arm': 1.2.4
optional: true
'@img/sharp-linux-s390x@0.33.5':
'@img/sharp-linux-ppc64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-s390x': 1.0.4
'@img/sharp-libvips-linux-ppc64': 1.2.4
optional: true
'@img/sharp-linux-x64@0.33.5':
'@img/sharp-linux-riscv64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linux-x64': 1.0.4
'@img/sharp-libvips-linux-riscv64': 1.2.4
optional: true
'@img/sharp-linuxmusl-arm64@0.33.5':
'@img/sharp-linux-s390x@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-arm64': 1.0.4
'@img/sharp-libvips-linux-s390x': 1.2.4
optional: true
'@img/sharp-linuxmusl-x64@0.33.5':
'@img/sharp-linux-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-x64': 1.0.4
'@img/sharp-libvips-linux-x64': 1.2.4
optional: true
'@img/sharp-wasm32@0.33.5':
'@img/sharp-linuxmusl-arm64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
optional: true
'@img/sharp-linuxmusl-x64@0.34.5':
optionalDependencies:
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
optional: true
'@img/sharp-wasm32@0.34.5':
dependencies:
'@emnapi/runtime': 1.4.5
'@emnapi/runtime': 1.7.1
optional: true
'@img/sharp-win32-ia32@0.33.5':
'@img/sharp-win32-arm64@0.34.5':
optional: true
'@img/sharp-win32-x64@0.33.5':
'@img/sharp-win32-ia32@0.34.5':
optional: true
'@img/sharp-win32-x64@0.34.5':
optional: true
'@isaacs/fs-minipass@4.0.1':
@@ -2281,32 +2369,34 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
'@next/env@15.2.4': {}
'@next/env@15.5.9': {}
'@next/swc-darwin-arm64@15.2.4':
'@next/swc-darwin-arm64@15.5.7':
optional: true
'@next/swc-darwin-x64@15.2.4':
'@next/swc-darwin-x64@15.5.7':
optional: true
'@next/swc-linux-arm64-gnu@15.2.4':
'@next/swc-linux-arm64-gnu@15.5.7':
optional: true
'@next/swc-linux-arm64-musl@15.2.4':
'@next/swc-linux-arm64-musl@15.5.7':
optional: true
'@next/swc-linux-x64-gnu@15.2.4':
'@next/swc-linux-x64-gnu@15.5.7':
optional: true
'@next/swc-linux-x64-musl@15.2.4':
'@next/swc-linux-x64-musl@15.5.7':
optional: true
'@next/swc-win32-arm64-msvc@15.2.4':
'@next/swc-win32-arm64-msvc@15.5.7':
optional: true
'@next/swc-win32-x64-msvc@15.2.4':
'@next/swc-win32-x64-msvc@15.5.7':
optional: true
'@panva/hkdf@1.2.1': {}
'@radix-ui/number@1.1.0': {}
'@radix-ui/primitive@1.1.1': {}
@@ -2999,8 +3089,6 @@ snapshots:
'@radix-ui/rect@1.1.0': {}
'@swc/counter@0.1.3': {}
'@swc/helpers@0.5.15':
dependencies:
tslib: 2.8.1
@@ -3155,12 +3243,10 @@ snapshots:
node-releases: 2.0.19
update-browserslist-db: 1.1.3(browserslist@4.25.3)
busboy@1.6.0:
dependencies:
streamsearch: 1.1.0
caniuse-lite@1.0.30001735: {}
caniuse-lite@1.0.30001760: {}
chownr@3.0.0: {}
class-variance-authority@0.7.1:
@@ -3183,26 +3269,6 @@ snapshots:
- '@types/react'
- '@types/react-dom'
color-convert@2.0.1:
dependencies:
color-name: 1.1.4
optional: true
color-name@1.1.4:
optional: true
color-string@1.9.1:
dependencies:
color-name: 1.1.4
simple-swizzle: 0.2.2
optional: true
color@4.2.3:
dependencies:
color-convert: 2.0.1
color-string: 1.9.1
optional: true
csstype@3.1.3: {}
d3-array@3.2.4:
@@ -3251,6 +3317,9 @@ snapshots:
detect-libc@2.0.4: {}
detect-libc@2.1.2:
optional: true
detect-node-es@1.1.0: {}
dom-helpers@5.2.1:
@@ -3317,9 +3386,9 @@ snapshots:
fsevents@2.3.3:
optional: true
geist@1.4.2(next@15.2.4(react-dom@19.1.1(react@19.1.1))(react@19.1.1)):
geist@1.4.2(next@15.5.9(react-dom@19.1.1(react@19.1.1))(react@19.1.1)):
dependencies:
next: 15.2.4(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
next: 15.5.9(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
get-nonce@1.0.1: {}
@@ -3336,11 +3405,10 @@ snapshots:
internmap@2.0.3: {}
is-arrayish@0.3.2:
optional: true
jiti@2.5.1: {}
jose@6.1.0: {}
js-tokens@4.0.0: {}
lightningcss-darwin-arm64@1.30.1:
@@ -3412,32 +3480,36 @@ snapshots:
nanoid@3.3.11: {}
next-auth@5.0.0-beta.29(next@15.5.9(react-dom@19.1.1(react@19.1.1))(react@19.1.1))(react@19.1.1):
dependencies:
'@auth/core': 0.40.0
next: 15.5.9(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
react: 19.1.1
next-themes@0.4.6(react-dom@19.1.1(react@19.1.1))(react@19.1.1):
dependencies:
react: 19.1.1
react-dom: 19.1.1(react@19.1.1)
next@15.2.4(react-dom@19.1.1(react@19.1.1))(react@19.1.1):
next@15.5.9(react-dom@19.1.1(react@19.1.1))(react@19.1.1):
dependencies:
'@next/env': 15.2.4
'@swc/counter': 0.1.3
'@next/env': 15.5.9
'@swc/helpers': 0.5.15
busboy: 1.6.0
caniuse-lite: 1.0.30001735
caniuse-lite: 1.0.30001760
postcss: 8.4.31
react: 19.1.1
react-dom: 19.1.1(react@19.1.1)
styled-jsx: 5.1.6(react@19.1.1)
optionalDependencies:
'@next/swc-darwin-arm64': 15.2.4
'@next/swc-darwin-x64': 15.2.4
'@next/swc-linux-arm64-gnu': 15.2.4
'@next/swc-linux-arm64-musl': 15.2.4
'@next/swc-linux-x64-gnu': 15.2.4
'@next/swc-linux-x64-musl': 15.2.4
'@next/swc-win32-arm64-msvc': 15.2.4
'@next/swc-win32-x64-msvc': 15.2.4
sharp: 0.33.5
'@next/swc-darwin-arm64': 15.5.7
'@next/swc-darwin-x64': 15.5.7
'@next/swc-linux-arm64-gnu': 15.5.7
'@next/swc-linux-arm64-musl': 15.5.7
'@next/swc-linux-x64-gnu': 15.5.7
'@next/swc-linux-x64-musl': 15.5.7
'@next/swc-win32-arm64-msvc': 15.5.7
'@next/swc-win32-x64-msvc': 15.5.7
sharp: 0.34.5
transitivePeerDependencies:
- '@babel/core'
- babel-plugin-macros
@@ -3450,6 +3522,8 @@ snapshots:
normalize-range@0.1.2: {}
oauth4webapi@3.8.2: {}
object-assign@4.1.1: {}
pg-cloudflare@1.2.7:
@@ -3513,6 +3587,12 @@ snapshots:
dependencies:
xtend: 4.0.2
preact-render-to-string@6.5.11(preact@10.24.3):
dependencies:
preact: 10.24.3
preact@10.24.3: {}
prop-types@15.8.1:
dependencies:
loose-envify: 1.4.0
@@ -3611,39 +3691,39 @@ snapshots:
scheduler@0.26.0: {}
semver@7.7.2:
semver@7.7.3:
optional: true
sharp@0.33.5:
sharp@0.34.5:
dependencies:
color: 4.2.3
detect-libc: 2.0.4
semver: 7.7.2
'@img/colour': 1.0.0
detect-libc: 2.1.2
semver: 7.7.3
optionalDependencies:
'@img/sharp-darwin-arm64': 0.33.5
'@img/sharp-darwin-x64': 0.33.5
'@img/sharp-libvips-darwin-arm64': 1.0.4
'@img/sharp-libvips-darwin-x64': 1.0.4
'@img/sharp-libvips-linux-arm': 1.0.5
'@img/sharp-libvips-linux-arm64': 1.0.4
'@img/sharp-libvips-linux-s390x': 1.0.4
'@img/sharp-libvips-linux-x64': 1.0.4
'@img/sharp-libvips-linuxmusl-arm64': 1.0.4
'@img/sharp-libvips-linuxmusl-x64': 1.0.4
'@img/sharp-linux-arm': 0.33.5
'@img/sharp-linux-arm64': 0.33.5
'@img/sharp-linux-s390x': 0.33.5
'@img/sharp-linux-x64': 0.33.5
'@img/sharp-linuxmusl-arm64': 0.33.5
'@img/sharp-linuxmusl-x64': 0.33.5
'@img/sharp-wasm32': 0.33.5
'@img/sharp-win32-ia32': 0.33.5
'@img/sharp-win32-x64': 0.33.5
optional: true
simple-swizzle@0.2.2:
dependencies:
is-arrayish: 0.3.2
'@img/sharp-darwin-arm64': 0.34.5
'@img/sharp-darwin-x64': 0.34.5
'@img/sharp-libvips-darwin-arm64': 1.2.4
'@img/sharp-libvips-darwin-x64': 1.2.4
'@img/sharp-libvips-linux-arm': 1.2.4
'@img/sharp-libvips-linux-arm64': 1.2.4
'@img/sharp-libvips-linux-ppc64': 1.2.4
'@img/sharp-libvips-linux-riscv64': 1.2.4
'@img/sharp-libvips-linux-s390x': 1.2.4
'@img/sharp-libvips-linux-x64': 1.2.4
'@img/sharp-libvips-linuxmusl-arm64': 1.2.4
'@img/sharp-libvips-linuxmusl-x64': 1.2.4
'@img/sharp-linux-arm': 0.34.5
'@img/sharp-linux-arm64': 0.34.5
'@img/sharp-linux-ppc64': 0.34.5
'@img/sharp-linux-riscv64': 0.34.5
'@img/sharp-linux-s390x': 0.34.5
'@img/sharp-linux-x64': 0.34.5
'@img/sharp-linuxmusl-arm64': 0.34.5
'@img/sharp-linuxmusl-x64': 0.34.5
'@img/sharp-wasm32': 0.34.5
'@img/sharp-win32-arm64': 0.34.5
'@img/sharp-win32-ia32': 0.34.5
'@img/sharp-win32-x64': 0.34.5
optional: true
sonner@1.7.4(react-dom@19.1.1(react@19.1.1))(react@19.1.1):
@@ -3655,8 +3735,6 @@ snapshots:
split2@4.2.0: {}
streamsearch@1.1.0: {}
styled-jsx@5.1.6(react@19.1.1):
dependencies:
client-only: 0.0.1

0
public/.gitkeep Normal file
View File

View File

@@ -2,6 +2,10 @@ import { getPool } from "./database";
import { Team, SkillCategory } from "@/lib/types";
import { TeamMember, TeamStats, DirectionStats } from "@/lib/admin-types";
import { SkillsService } from "./skills-service";
import {
COVERAGE_OBJECTIVES,
generateSkillCoverageSQL,
} from "@/lib/evaluation-utils";
export class AdminService {
/**
@@ -9,8 +13,11 @@ export class AdminService {
*/
static async getTeamsStats(): Promise<TeamStats[]> {
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
// Récupérer toutes les équipes avec leurs membres et évaluations
const query = `
WITH team_members AS (
@@ -56,14 +63,7 @@ export class AdminService {
s.icon as skill_icon,
s.importance,
AVG(ss.level_numeric) as avg_level,
COALESCE(
SUM(CASE
WHEN ss.level_numeric >= 2 THEN 100.0 -- autonomous ou expert
WHEN ss.level_numeric = 1 THEN 50.0 -- not-autonomous
ELSE 0.0 -- never
END) / NULLIF(COUNT(*), 0),
0
) as coverage
${generateSkillCoverageSQL("ss.level_numeric")} as coverage
FROM skill_stats ss
JOIN skills s ON ss.skill_id = s.id
WHERE ss.skill_name IS NOT NULL
@@ -73,11 +73,17 @@ export class AdminService {
SELECT
team_id,
COALESCE(
AVG(CASE WHEN importance = 'incontournable' THEN coverage ELSE NULL END),
AVG(CASE
WHEN importance = 'incontournable' THEN coverage
ELSE NULL
END),
0
) as incontournable_coverage,
COALESCE(
AVG(CASE WHEN importance = 'majeure' THEN coverage ELSE NULL END),
AVG(CASE
WHEN importance = 'majeure' THEN coverage
ELSE NULL
END),
0
) as majeure_coverage
FROM team_skill_averages
@@ -149,7 +155,8 @@ export class AdminService {
ORDER BY tm.direction, tm.team_name
`;
const result = await pool.query(query);
const result = await client.query(query);
await client.query("COMMIT");
return result.rows.map((row) => ({
teamId: row.team_id,
@@ -168,8 +175,11 @@ export class AdminService {
),
}));
} catch (error) {
await client.query("ROLLBACK");
console.error("Error fetching teams stats:", error);
throw new Error("Failed to fetch teams statistics");
} finally {
client.release();
}
}
@@ -241,10 +251,15 @@ export class AdminService {
skills: any[];
}> {
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
const [categoriesResult, skills] = await Promise.all([
pool.query("SELECT id, name, icon FROM skill_categories ORDER BY name"),
client.query(
"SELECT id, name, icon FROM skill_categories ORDER BY name"
),
SkillsService.getAllSkillsWithUsage(),
]);
@@ -254,13 +269,18 @@ export class AdminService {
skills: [],
}));
await client.query("COMMIT");
return {
skillCategories,
skills,
};
} catch (error) {
await client.query("ROLLBACK");
console.error("Error fetching skills page data:", error);
throw new Error("Failed to fetch skills page data");
} finally {
client.release();
}
}
@@ -308,13 +328,16 @@ export class AdminService {
users: any[];
}> {
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
const [teamsResult, usersResult] = await Promise.all([
pool.query(
client.query(
"SELECT id, name, direction FROM teams ORDER BY direction, name"
),
pool.query(`
client.query(`
SELECT
u.uuid_id as uuid,
u.first_name as "firstName",
@@ -330,13 +353,18 @@ export class AdminService {
`),
]);
await client.query("COMMIT");
return {
teams: teamsResult.rows,
users: usersResult.rows,
};
} catch (error) {
await client.query("ROLLBACK");
console.error("Error fetching users page data:", error);
throw new Error("Failed to fetch users page data");
} finally {
client.release();
}
}
@@ -349,10 +377,13 @@ export class AdminService {
directionStats: DirectionStats[];
}> {
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
const [teamsResult, teamStats] = await Promise.all([
pool.query(
client.query(
"SELECT id, name, direction FROM teams ORDER BY direction, name"
),
AdminService.getTeamsStats(),
@@ -360,14 +391,19 @@ export class AdminService {
const directionStats = AdminService.generateDirectionStats(teamStats);
await client.query("COMMIT");
return {
teams: teamsResult.rows,
teamStats,
directionStats,
};
} catch (error) {
await client.query("ROLLBACK");
console.error("Error fetching teams page data:", error);
throw new Error("Failed to fetch teams page data");
} finally {
client.release();
}
}
@@ -381,13 +417,18 @@ export class AdminService {
directionStats: DirectionStats[];
}> {
const pool = getPool();
const client = await pool.connect();
try {
await client.query("BEGIN");
const [teamsResult, categoriesResult, teamStats] = await Promise.all([
pool.query(
client.query(
"SELECT id, name, direction FROM teams ORDER BY direction, name"
),
pool.query("SELECT id, name, icon FROM skill_categories ORDER BY name"),
client.query(
"SELECT id, name, icon FROM skill_categories ORDER BY name"
),
AdminService.getTeamsStats(),
]);
@@ -400,6 +441,8 @@ export class AdminService {
const directionStats = AdminService.generateDirectionStats(teamStats);
await client.query("COMMIT");
return {
teams,
skillCategories,
@@ -407,8 +450,11 @@ export class AdminService {
directionStats,
};
} catch (error) {
await client.query("ROLLBACK");
console.error("Error fetching overview page data:", error);
throw new Error("Failed to fetch overview page data");
} finally {
client.release();
}
}
}

View File

@@ -1,126 +0,0 @@
import { cookies } from "next/headers";
import { UserProfile } from "@/lib/types";
import { userService } from "@/services/user-service";
// Constantes pour les cookies
export const COOKIE_NAME = "session_token";
export const COOKIE_MAX_AGE = 30 * 24 * 60 * 60; // 30 jours
/**
* Service d'authentification côté serveur
* Implémente les méthodes qui nécessitent next/headers
*/
export class AuthService {
/**
* Récupère l'UUID utilisateur depuis le cookie côté serveur
*/
static async getUserUuidFromCookie(): Promise<string | null> {
const cookieStore = await cookies();
const userUuidCookie = cookieStore.get(COOKIE_NAME);
if (!userUuidCookie?.value) {
return null;
}
return userUuidCookie.value;
}
/**
* Vérifie si l'utilisateur est authentifié côté serveur
*/
static async isUserAuthenticated(): Promise<boolean> {
const userUuid = await this.getUserUuidFromCookie();
return !!userUuid;
}
/**
* Vérifie l'authentification et retourne le profil utilisateur
* @throws {Error} avec status 401 si non authentifié ou 404 si utilisateur non trouvé
*/
static async requireAuthenticatedUser(): Promise<{
userUuid: string;
userProfile: UserProfile;
}> {
const userUuid = await this.getUserUuidFromCookie();
if (!userUuid) {
const error = new Error("Utilisateur non authentifié");
(error as any).status = 401;
throw error;
}
const userProfile = await userService.getUserByUuid(userUuid);
if (!userProfile) {
const error = new Error("Utilisateur introuvable");
(error as any).status = 404;
throw error;
}
return { userUuid, userProfile };
}
/**
* Authentifie un utilisateur et retourne la configuration du cookie
* Note: Cette méthode est maintenant obsolète avec le nouveau système d'auth
* Utilisez login/register à la place
*/
static async authenticateUser(profile: UserProfile): Promise<{
userUuid: string;
cookieConfig: {
name: string;
value: string;
options: {
maxAge: number;
httpOnly: boolean;
secure: boolean;
sameSite: "lax" | "strict" | "none";
path: string;
};
};
}> {
// Vérifier si l'utilisateur existe déjà avec ces informations
const existingUser = await userService.findUserByProfile(profile);
if (!existingUser) {
throw new Error(
"Utilisateur non trouvé. Veuillez vous connecter avec votre email et mot de passe."
);
}
return {
userUuid: existingUser.uuid,
cookieConfig: {
name: COOKIE_NAME,
value: existingUser.uuid,
options: {
maxAge: COOKIE_MAX_AGE,
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
},
},
};
}
/**
* Crée une nouvelle session pour un utilisateur
*/
static async createSession(userUuid: string): Promise<string> {
// Pour l'instant, on utilise l'UUID comme token de session
// Plus tard, on pourra implémenter un système de JWT ou de sessions en base
return userUuid;
}
/**
* Valide un token de session et retourne l'UUID utilisateur
*/
static async validateSession(sessionToken: string): Promise<string | null> {
// Pour l'instant, on considère que le token est valide s'il correspond à un UUID
// Plus tard, on pourra ajouter une validation plus robuste
if (sessionToken && sessionToken.length > 0) {
return sessionToken;
}
return null;
}
}

View File

@@ -1,6 +1,5 @@
import { getPool } from "./database";
import { userService } from "./user-service";
import { AuthService } from "./auth-service";
import {
UserEvaluation,
UserProfile,
@@ -121,8 +120,12 @@ export class EvaluationService {
try {
await client.query("BEGIN");
// 1. Upsert user avec UUID
const userUuid = await AuthService.getUserUuidFromCookie();
// 1. Récupérer le userUuid depuis le profile de l'évaluation
const existingUser = await userService.findUserByProfile(evaluation.profile);
if (!existingUser) {
throw new Error("Utilisateur non trouvé");
}
const userUuid = existingUser.uuid;
// 2. Upsert user_evaluation avec user_uuid
const userEvalQuery = `
@@ -624,7 +627,12 @@ export class EvaluationService {
try {
await client.query("BEGIN");
const userUuid = await AuthService.getUserUuidFromCookie();
// Récupérer le userUuid depuis le profile
const existingUser = await userService.findUserByProfile(profile);
if (!existingUser) {
throw new Error("Utilisateur non trouvé");
}
const userUuid = existingUser.uuid;
// Supprimer directement la skill evaluation
const deleteQuery = `

View File

@@ -22,6 +22,3 @@ export { AdminService } from "./admin-service";
// Admin types (can be imported anywhere)
export type { TeamMember, TeamStats, DirectionStats } from "@/lib/admin-types";
// Server auth service (server-side only)
export { AuthService, COOKIE_NAME, COOKIE_MAX_AGE } from "./auth-service";

View File

@@ -7,7 +7,12 @@ import {
TeamMember,
TeamMemberSkill,
} from "@/lib/team-review-types";
import { SkillLevel } from "@/lib/types";
import { SKILL_LEVEL_VALUES, SkillImportance } from "@/lib/types";
import {
COVERAGE_OBJECTIVES,
isCoverageBelowObjective,
calculateSkillCoverage,
} from "@/lib/evaluation-utils";
export class TeamReviewService {
static async getTeamReviewData(teamId: string): Promise<TeamReviewData> {
@@ -100,7 +105,11 @@ export class TeamReviewService {
skillName: row.skill_name,
category: row.category,
importance: row.importance || "standard",
level: row.level as SkillLevel,
level: row.level as
| "never"
| "not-autonomous"
| "autonomous"
| "expert",
canMentor: row.can_mentor || false,
wantsToLearn: row.wants_to_learn || false,
};
@@ -140,16 +149,25 @@ export class TeamReviewService {
const teamMembers = evaluations.filter((e) => e.level).length;
const totalTeamMembers = membersMap.size;
const coverage =
totalTeamMembers > 0 ? (teamMembers / totalTeamMembers) * 100 : 0;
// Déterminer le niveau de risque en fonction de l'importance
// Calculer la couverture en fonction des niveaux
const levels = evaluations.map((e) =>
e.level
? SKILL_LEVEL_VALUES[e.level as keyof typeof SKILL_LEVEL_VALUES]
: 0
);
const coverage = calculateSkillCoverage(levels, totalTeamMembers);
// Déterminer le niveau de risque en fonction de l'importance et de la couverture
const coverageObjective = COVERAGE_OBJECTIVES[skill.importance as SkillImportance];
const risk =
skill.importance === "incontournable" && experts === 0
skill.importance === "incontournable" && coverage < coverageObjective
? "high"
: skill.importance === "majeure" && experts === 0 && mentors === 0
: skill.importance === "majeure" &&
coverage < coverageObjective &&
experts === 0
? "high"
: experts === 0 && mentors === 0
: coverage < coverageObjective
? "medium"
: "low";
@@ -159,7 +177,7 @@ export class TeamReviewService {
category: skill.category,
icon: skill.icon,
importance: skill.importance || "standard",
team_members: teamMembers,
teamMembers,
experts,
mentors,
learners,
@@ -175,8 +193,8 @@ export class TeamReviewService {
if (!categoriesMap.has(skill.category)) {
categoriesMap.set(skill.category, {
category: skill.category,
total_skills: 0,
covered_skills: 0,
totalSkills: 0,
coveredSkills: 0,
experts: 0,
mentors: 0,
learners: 0,
@@ -189,13 +207,13 @@ export class TeamReviewService {
}
const categoryStats = categoriesMap.get(skill.category)!;
categoryStats.total_skills++;
categoryStats.totalSkills++;
const skillGap = skillGaps.find(
(gap) => gap.skillId === skill.skill_id
);
if (skillGap) {
if (skillGap.team_members > 0) categoryStats.covered_skills++;
if (skillGap.teamMembers > 0) categoryStats.coveredSkills++;
categoryStats.experts += skillGap.experts;
categoryStats.mentors += skillGap.mentors;
categoryStats.learners += skillGap.learners;
@@ -203,11 +221,14 @@ export class TeamReviewService {
// Calculer la couverture des compétences critiques
if (
skillGap.importance === "incontournable" &&
skillGap.coverage > 50
!isCoverageBelowObjective(skillGap.coverage, skillGap.importance)
) {
categoryStats.criticalSkillsCoverage.incontournable++;
}
if (skillGap.importance === "majeure" && skillGap.coverage > 50) {
if (
skillGap.importance === "majeure" &&
!isCoverageBelowObjective(skillGap.coverage, skillGap.importance)
) {
categoryStats.criticalSkillsCoverage.majeure++;
}
}
@@ -219,8 +240,8 @@ export class TeamReviewService {
).map((category) => ({
...category,
coverage:
category.total_skills > 0
? (category.covered_skills / category.total_skills) * 100
category.totalSkills > 0
? (category.coveredSkills / category.totalSkills) * 100
: 0,
}));
@@ -236,7 +257,14 @@ export class TeamReviewService {
incontournable:
(skillGaps
.filter((gap) => gap.importance === "incontournable")
.reduce((acc, gap) => acc + (gap.coverage > 50 ? 1 : 0), 0) /
.reduce(
(acc, gap) =>
acc +
(!isCoverageBelowObjective(gap.coverage, gap.importance)
? 1
: 0),
0
) /
Math.max(
1,
skillGaps.filter((gap) => gap.importance === "incontournable")
@@ -246,7 +274,14 @@ export class TeamReviewService {
majeure:
(skillGaps
.filter((gap) => gap.importance === "majeure")
.reduce((acc, gap) => acc + (gap.coverage > 50 ? 1 : 0), 0) /
.reduce(
(acc, gap) =>
acc +
(!isCoverageBelowObjective(gap.coverage, gap.importance)
? 1
: 0),
0
) /
Math.max(
1,
skillGaps.filter((gap) => gap.importance === "majeure").length
@@ -298,7 +333,9 @@ export class TeamReviewService {
// Analyser les gaps critiques par importance
const uncoveredIncontournables = skillGaps.filter(
(gap) => gap.importance === "incontournable" && gap.coverage < 50
(gap) =>
gap.importance === "incontournable" &&
isCoverageBelowObjective(gap.coverage, gap.importance)
);
if (uncoveredIncontournables.length > 0) {
recommendations.push(
@@ -311,7 +348,9 @@ export class TeamReviewService {
}
const uncoveredMajeures = skillGaps.filter(
(gap) => gap.importance === "majeure" && gap.coverage < 30
(gap) =>
gap.importance === "majeure" &&
isCoverageBelowObjective(gap.coverage, gap.importance)
);
if (uncoveredMajeures.length > 0) {
recommendations.push(
@@ -338,7 +377,7 @@ export class TeamReviewService {
// Analyser la couverture des catégories
const lowCoverageCategories = categoryCoverage
.filter((cat) => cat.coverage < 50)
.filter((cat) => cat.coverage < COVERAGE_OBJECTIVES.majeure)
.map((cat) => cat.category);
if (lowCoverageCategories.length > 0) {
recommendations.push(

28
types/next-auth.d.ts vendored Normal file
View File

@@ -0,0 +1,28 @@
import { DefaultSession } from "next-auth";
declare module "next-auth" {
interface Session {
user: {
id: string;
teamId: string;
firstName: string;
lastName: string;
} & DefaultSession["user"];
}
interface User {
teamId: string;
firstName: string;
lastName: string;
}
}
declare module "next-auth/jwt" {
interface JWT {
id: string;
teamId: string;
firstName: string;
lastName: string;
}
}