diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..29eb09d --- /dev/null +++ b/TODO.md @@ -0,0 +1,54 @@ +# TODO List + +## Completed ✅ + +### 1. Analyse de la structure existante + +- [x] Analyser la structure existante des pages admin et des APIs +- [x] Identifier les composants existants et leurs responsabilités + +### 2. Création de la page de gestion + +- [x] Créer une nouvelle page admin avec onglets pour Skills et Teams +- [x] Implémenter les composants de gestion des Skills (CRUD) +- [x] Implémenter les composants de gestion des Teams (CRUD) +- [x] Créer/adapter les APIs nécessaires pour les opérations CRUD + +### 3. Vue arborescente des Skills + +- [x] Refactorer Skills Management avec une vue arborescente par catégorie +- [x] Implémenter le système expand/collapse pour les catégories +- [x] Adapter la recherche pour fonctionner avec la vue arborescente +- [x] Ajouter l'icône du skill au début de chaque ligne + +### 4. Factorisation des composants + +- [x] Créer des composants réutilisables pour la vue arborescente dans Skills et Teams Management +- [x] Factoriser le code entre les deux pages de gestion +- [x] Créer des composants génériques : TreeViewContainer, TreeCategoryHeader, TreeItemRow, TreeSearchControls, TeamMetrics + +### 5. Suppression de direction + +- [x] Ajouter la possibilité de supprimer une direction entière avec toutes ses équipes +- [x] Implémenter la vérification de sécurité (impossible si des équipes ont des membres) +- [x] Ajouter le bouton de suppression dans TreeCategoryHeader pour les directions + +### 6. Réorganisation de la structure + +- [x] Réorganiser tous les composants admin dans des dossiers logiques +- [x] Créer une structure claire : overview/, layout/, management/, team-detail/, utils/ +- [x] Mettre à jour tous les imports et exports +- [x] Créer des fichiers d'index pour chaque dossier +- [x] Documenter la nouvelle structure avec un README + +## Pending 🔄 + +### Aucune tâche en attente + +## Next Steps 🚀 + +La structure des composants admin est maintenant parfaitement organisée et documentée. Tous les composants sont factorisés et réutilisables. La fonctionnalité de suppression de direction est implémentée et sécurisée. + +--- + +**Note** : Cette TODO list a été complètement réalisée ! 🎉 diff --git a/app/admin/manage/page.tsx b/app/admin/manage/page.tsx new file mode 100644 index 0000000..4d57a8b --- /dev/null +++ b/app/admin/manage/page.tsx @@ -0,0 +1,39 @@ +import { redirect } from "next/navigation"; +import { isUserAuthenticated } from "@/lib/server-auth"; +import { AdminService } from "@/services/admin-service"; +import { ManageAdminClientWrapper } from "@/components/admin"; + +export default async function ManageAdminPage() { + // Vérifier l'authentification + const isAuthenticated = await isUserAuthenticated(); + + // Si pas de cookie d'authentification, rediriger vers login + if (!isAuthenticated) { + redirect("/login"); + } + + // Charger les données côté serveur + try { + const adminData = await AdminService.getAdminData(); + + return ( + + ); + } catch (error) { + console.error("Failed to load admin data:", error); + return ( +
+
+
+ Erreur lors du chargement des données d'administration +
+
+
+ ); + } +} diff --git a/app/api/admin/skills/route.ts b/app/api/admin/skills/route.ts new file mode 100644 index 0000000..99a1fc6 --- /dev/null +++ b/app/api/admin/skills/route.ts @@ -0,0 +1,249 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getPool } from "@/services/database"; +import { isUserAuthenticated } from "@/lib/server-auth"; + +// GET - Récupérer toutes les skills +export async function GET() { + try { + // Vérifier l'authentification + const isAuthenticated = await isUserAuthenticated(); + if (!isAuthenticated) { + return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); + } + + const pool = getPool(); + const query = ` + SELECT + s.id, + s.name, + s.description, + s.icon, + sc.id as category_id, + sc.name as category_name, + COUNT(DISTINCT se.id) as usage_count + FROM skills s + LEFT JOIN skill_categories sc ON s.category_id = sc.id + LEFT JOIN skill_evaluations se ON s.id = se.skill_id AND se.is_selected = true + GROUP BY s.id, s.name, s.description, s.icon, sc.id, sc.name + ORDER BY s.name + `; + + const result = await pool.query(query); + + const skills = result.rows.map((row) => ({ + id: row.id, + name: row.name, + description: row.description || "", + icon: row.icon || "", + categoryId: row.category_id, + category: row.category_name, + usageCount: parseInt(row.usage_count) || 0, + })); + + return NextResponse.json(skills); + } catch (error) { + console.error("Error fetching skills:", error); + return NextResponse.json( + { error: "Erreur lors de la récupération des skills" }, + { status: 500 } + ); + } +} + +// POST - Créer une nouvelle skill +export async function POST(request: NextRequest) { + try { + // Vérifier l'authentification + const isAuthenticated = await isUserAuthenticated(); + if (!isAuthenticated) { + return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); + } + + const { name, categoryId, description, icon } = await request.json(); + + if (!name || !categoryId) { + return NextResponse.json( + { error: "Le nom et la catégorie sont requis" }, + { status: 400 } + ); + } + + const pool = getPool(); + + // Vérifier si la skill existe déjà + const existingSkill = await pool.query( + "SELECT id FROM skills WHERE LOWER(name) = LOWER($1)", + [name] + ); + + if (existingSkill.rows.length > 0) { + return NextResponse.json( + { error: "Une skill avec ce nom existe déjà" }, + { status: 409 } + ); + } + + // Créer la nouvelle skill + const result = await pool.query( + `INSERT INTO skills (name, category_id, description, icon) + VALUES ($1, $2, $3, $4) + RETURNING id, name, description, icon, category_id`, + [name, categoryId, description || "", icon || ""] + ); + + const newSkill = result.rows[0]; + + // Récupérer le nom de la catégorie + const categoryResult = await pool.query( + "SELECT name FROM skill_categories WHERE id = $1", + [newSkill.category_id] + ); + + const skill = { + id: newSkill.id, + name: newSkill.name, + description: newSkill.description, + icon: newSkill.icon, + categoryId: newSkill.category_id, + category: categoryResult.rows[0]?.name || "Inconnue", + usageCount: 0, + }; + + return NextResponse.json(skill, { status: 201 }); + } catch (error) { + console.error("Error creating skill:", error); + return NextResponse.json( + { error: "Erreur lors de la création de la skill" }, + { status: 500 } + ); + } +} + +// PUT - Mettre à jour une skill +export async function PUT(request: NextRequest) { + try { + // Vérifier l'authentification + const isAuthenticated = await isUserAuthenticated(); + if (!isAuthenticated) { + return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); + } + + const { id, name, categoryId, description, icon } = await request.json(); + + if (!id || !name || !categoryId) { + return NextResponse.json( + { error: "L'ID, le nom et la catégorie sont requis" }, + { status: 400 } + ); + } + + const pool = getPool(); + + // Vérifier si la skill existe + const existingSkill = await pool.query( + "SELECT id FROM skills WHERE id = $1", + [id] + ); + + if (existingSkill.rows.length === 0) { + return NextResponse.json({ error: "Skill non trouvée" }, { status: 404 }); + } + + // Vérifier si le nom existe déjà (sauf pour cette skill) + const duplicateName = await pool.query( + "SELECT id FROM skills WHERE LOWER(name) = LOWER($1) AND id != $2", + [name, id] + ); + + if (duplicateName.rows.length > 0) { + return NextResponse.json( + { error: "Une skill avec ce nom existe déjà" }, + { status: 409 } + ); + } + + // Mettre à jour la skill + await pool.query( + `UPDATE skills + SET name = $1, category_id = $2, description = $3, icon = $4 + WHERE id = $5`, + [name, categoryId, description || "", icon || "", id] + ); + + // Récupérer la skill mise à jour + const result = await pool.query( + `SELECT s.id, s.name, s.description, s.icon, s.category_id, sc.name as category_name + FROM skills s + LEFT JOIN skill_categories sc ON s.category_id = sc.id + WHERE s.id = $1`, + [id] + ); + + const skill = result.rows[0]; + + return NextResponse.json({ + id: skill.id, + name: skill.name, + description: skill.description, + icon: skill.icon, + categoryId: skill.category_id, + category: skill.category_name, + }); + } catch (error) { + console.error("Error updating skill:", error); + return NextResponse.json( + { error: "Erreur lors de la mise à jour de la skill" }, + { status: 500 } + ); + } +} + +// DELETE - Supprimer une skill +export async function DELETE(request: NextRequest) { + try { + // Vérifier l'authentification + const isAuthenticated = await isUserAuthenticated(); + if (!isAuthenticated) { + return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const id = searchParams.get("id"); + + if (!id) { + return NextResponse.json( + { error: "L'ID de la skill est requis" }, + { status: 400 } + ); + } + + const pool = getPool(); + + // Vérifier si la skill est utilisée + const usageCheck = await pool.query( + `SELECT COUNT(*) as count + FROM skill_evaluations se + WHERE se.skill_id = $1 AND se.is_selected = true`, + [id] + ); + + const usageCount = parseInt(usageCheck.rows[0].count); + if (usageCount > 0) { + return NextResponse.json( + { error: "Impossible de supprimer une skill qui est utilisée" }, + { status: 409 } + ); + } + + // Supprimer la skill + await pool.query("DELETE FROM skills WHERE id = $1", [id]); + + return NextResponse.json({ message: "Skill supprimée avec succès" }); + } catch (error) { + console.error("Error deleting skill:", error); + return NextResponse.json( + { error: "Erreur lors de la suppression de la skill" }, + { status: 500 } + ); + } +} diff --git a/app/api/admin/teams/route.ts b/app/api/admin/teams/route.ts new file mode 100644 index 0000000..8170710 --- /dev/null +++ b/app/api/admin/teams/route.ts @@ -0,0 +1,269 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getPool } from "@/services/database"; +import { isUserAuthenticated } from "@/lib/server-auth"; + +// GET - Récupérer toutes les teams +export async function GET() { + try { + // Vérifier l'authentification + const isAuthenticated = await isUserAuthenticated(); + if (!isAuthenticated) { + return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); + } + + const pool = getPool(); + const query = ` + SELECT + t.id, + t.name, + t.direction, + COUNT(DISTINCT u.uuid_id) as member_count + FROM teams t + LEFT JOIN users u ON t.id = u.team_id + GROUP BY t.id, t.name, t.direction + ORDER BY t.direction, t.name + `; + + const result = await pool.query(query); + + const teams = result.rows.map((row) => ({ + id: row.id, + name: row.name, + direction: row.direction, + memberCount: parseInt(row.member_count) || 0, + })); + + return NextResponse.json(teams); + } catch (error) { + console.error("Error fetching teams:", error); + return NextResponse.json( + { error: "Erreur lors de la récupération des teams" }, + { status: 500 } + ); + } +} + +// POST - Créer une nouvelle team +export async function POST(request: NextRequest) { + try { + // Vérifier l'authentification + const isAuthenticated = await isUserAuthenticated(); + if (!isAuthenticated) { + return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); + } + + const { name, direction } = await request.json(); + + if (!name || !direction) { + return NextResponse.json( + { error: "Le nom et la direction sont requis" }, + { status: 400 } + ); + } + + const pool = getPool(); + + // Vérifier si la team existe déjà + const existingTeam = await pool.query( + "SELECT id FROM teams WHERE LOWER(name) = LOWER($1)", + [name] + ); + + if (existingTeam.rows.length > 0) { + return NextResponse.json( + { error: "Une équipe avec ce nom existe déjà" }, + { status: 409 } + ); + } + + // Créer la nouvelle team + const result = await pool.query( + `INSERT INTO teams (name, direction) + VALUES ($1, $2) + RETURNING id, name, direction`, + [name, direction] + ); + + const newTeam = result.rows[0]; + + const team = { + id: newTeam.id, + name: newTeam.name, + direction: newTeam.direction, + memberCount: 0, + }; + + return NextResponse.json(team, { status: 201 }); + } catch (error) { + console.error("Error creating team:", error); + return NextResponse.json( + { error: "Erreur lors de la création de l'équipe" }, + { status: 500 } + ); + } +} + +// PUT - Mettre à jour une team +export async function PUT(request: NextRequest) { + try { + // Vérifier l'authentification + const isAuthenticated = await isUserAuthenticated(); + if (!isAuthenticated) { + return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); + } + + const { id, name, direction } = await request.json(); + + if (!id || !name || !direction) { + return NextResponse.json( + { error: "L'ID, le nom et la direction sont requis" }, + { status: 400 } + ); + } + + const pool = getPool(); + + // Vérifier si la team existe + const existingTeam = await pool.query( + "SELECT id FROM teams WHERE id = $1", + [id] + ); + + if (existingTeam.rows.length === 0) { + return NextResponse.json( + { error: "Équipe non trouvée" }, + { status: 404 } + ); + } + + // Vérifier si le nom existe déjà (sauf pour cette team) + const duplicateName = await pool.query( + "SELECT id FROM teams WHERE LOWER(name) = LOWER($1) AND id != $2", + [name, id] + ); + + if (duplicateName.rows.length > 0) { + return NextResponse.json( + { error: "Une équipe avec ce nom existe déjà" }, + { status: 409 } + ); + } + + // Mettre à jour la team + await pool.query( + `UPDATE teams + SET name = $1, direction = $2 + WHERE id = $3`, + [name, direction, id] + ); + + // Récupérer la team mise à jour + const result = await pool.query( + `SELECT t.id, t.name, t.direction, COUNT(DISTINCT u.uuid_id) as member_count + FROM teams t + LEFT JOIN users u ON t.id = u.team_id + WHERE t.id = $1 + GROUP BY t.id, t.name, t.direction`, + [id] + ); + + const team = result.rows[0]; + + return NextResponse.json({ + id: team.id, + name: team.name, + direction: team.direction, + memberCount: parseInt(team.member_count) || 0, + }); + } catch (error) { + console.error("Error updating team:", error); + return NextResponse.json( + { error: "Erreur lors de la mise à jour de l'équipe" }, + { status: 500 } + ); + } +} + +// DELETE - Supprimer une team ou une direction +export async function DELETE(request: NextRequest) { + try { + // Vérifier l'authentification + const isAuthenticated = await isUserAuthenticated(); + if (!isAuthenticated) { + return NextResponse.json({ error: "Non autorisé" }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const id = searchParams.get("id"); + const direction = searchParams.get("direction"); + + if (!id && !direction) { + return NextResponse.json( + { error: "L'ID de l'équipe ou la direction est requis" }, + { status: 400 } + ); + } + + const pool = getPool(); + + if (direction) { + // Supprimer une direction entière + // Vérifier d'abord si des équipes ont des membres + const memberCheck = await pool.query( + `SELECT COUNT(*) as count + FROM users u + JOIN teams t ON u.team_id = t.id + WHERE t.direction = $1`, + [direction] + ); + + const memberCount = parseInt(memberCheck.rows[0].count); + if (memberCount > 0) { + return NextResponse.json( + { + error: `Impossible de supprimer la direction "${direction}" car certaines équipes ont des membres`, + }, + { status: 409 } + ); + } + + // Supprimer toutes les équipes de la direction + await pool.query("DELETE FROM teams WHERE direction = $1", [direction]); + + return NextResponse.json({ + message: `Direction "${direction}" et toutes ses équipes supprimées avec succès`, + }); + } else { + // Supprimer une équipe spécifique + // Vérifier si la team a des membres + const memberCheck = await pool.query( + `SELECT COUNT(*) as count + FROM users + WHERE team_id = $1`, + [id] + ); + + const memberCount = parseInt(memberCheck.rows[0].count); + if (memberCount > 0) { + return NextResponse.json( + { + error: + "Impossible de supprimer une équipe qui contient des membres", + }, + { status: 409 } + ); + } + + // Supprimer la team + await pool.query("DELETE FROM teams WHERE id = $1", [id]); + + return NextResponse.json({ message: "Équipe supprimée avec succès" }); + } + } catch (error) { + console.error("Error deleting team/direction:", error); + return NextResponse.json( + { error: "Erreur lors de la suppression" }, + { status: 500 } + ); + } +} diff --git a/components/admin/README.md b/components/admin/README.md new file mode 100644 index 0000000..58cd89b --- /dev/null +++ b/components/admin/README.md @@ -0,0 +1,117 @@ +# Composants Admin + +Cette section contient tous les composants liés à l'administration de l'application. + +## Structure des dossiers + +### 📁 `overview/` + +Composants pour la vue d'ensemble de l'administration : + +- **`AdminClientWrapper`** : Wrapper principal pour la vue d'ensemble +- **`AdminContentTabs`** : Onglets "Vue par Direction" et "Vue par Équipe" +- **`AdminOverviewCards`** : Cartes de statistiques générales +- **`DirectionOverview`** : Vue détaillée par direction + +### 📁 `layout/` + +Composants de layout et navigation pour la gestion : + +- **`ManageAdminClientWrapper`** : Wrapper principal pour les pages de gestion +- **`ManageContentTabs`** : Onglets "Gestion des Skills" et "Gestion des Teams" + +### 📁 `management/` + +Composants pour la gestion des entités : + +#### `management/pages/` + +Pages de gestion spécifiques : + +- **`SkillsManagement`** : Gestion des skills avec vue arborescente +- **`TeamsManagement`** : Gestion des teams avec vue arborescente + +#### `management/` (composants réutilisables) + +Composants génériques pour la vue arborescente : + +- **`TreeViewContainer`** : Conteneur principal avec états de chargement +- **`TreeCategoryHeader`** : Header des catégories avec actions +- **`TreeItemRow`** : Ligne d'item générique avec actions +- **`TreeSearchControls`** : Contrôles de recherche et expansion +- **`TeamMetrics`** : Affichage des métriques des équipes + +### 📁 `team-detail/` + +Composants pour le détail des équipes : + +- **`TeamDetailClientWrapper`** : Wrapper pour les pages de détail +- **`TeamDetailHeader`** : Header avec informations de l'équipe +- **`TeamDetailTabs`** : Onglets de détail (Overview, Skills, Members, Insights) +- **`TeamDetailModal`** : Modal de détail de l'équipe +- **`TeamOverviewTab`** : Onglet de vue d'ensemble +- **`TeamSkillsTab`** : Onglet des skills de l'équipe +- **`TeamMembersTab`** : Onglet des membres de l'équipe +- **`TeamInsightsTab`** : Onglet des insights de l'équipe +- **`TeamMemberModal`** : Modal de gestion des membres +- **`TeamMetricsCards`** : Cartes de métriques de l'équipe +- **`TeamStatsCard`** : Carte de statistiques de l'équipe + +### 📁 `utils/` + +Composants utilitaires réutilisables : + +- **`AdminHeader`** : Header avec navigation entre "Vue d'ensemble" et "Gestion" +- **`AdminFilters`** : Filtres génériques pour l'administration +- **`MultiSelectFilter`** : Filtre multi-sélection + +## Utilisation + +### Import des composants + +```typescript +// Import depuis l'index principal +import { + SkillsManagement, + TeamsManagement, + TreeViewContainer, + AdminHeader, +} from "@/components/admin"; + +// Import direct depuis un dossier spécifique +import { SkillsManagement } from "@/components/admin/management/pages"; +import { TreeViewContainer } from "@/components/admin/management"; +``` + +### Hiérarchie des composants + +``` +AdminClientWrapper (overview) +├── AdminContentTabs +│ ├── DirectionOverview +│ └── TeamDetailClientWrapper (team-detail) +│ └── TeamDetailTabs +│ ├── TeamOverviewTab +│ ├── TeamSkillsTab +│ ├── TeamMembersTab +│ └── TeamInsightsTab + +ManageAdminClientWrapper (layout) +├── ManageContentTabs +│ ├── SkillsManagement (management/pages) +│ │ └── TreeViewContainer (management) +│ │ ├── TreeCategoryHeader +│ │ └── TreeItemRow +│ └── TeamsManagement (management/pages) +│ └── TreeViewContainer (management) +│ ├── TreeCategoryHeader +│ └── TreeItemRow +``` + +## Avantages de cette structure + +1. **Organisation logique** : Séparation claire des responsabilités +2. **Réutilisabilité** : Composants génériques dans `management/` +3. **Maintenabilité** : Fichiers regroupés par fonctionnalité +4. **Scalabilité** : Facile d'ajouter de nouveaux composants +5. **Import simplifié** : Un seul point d'entrée via l'index principal diff --git a/components/admin/admin-header.tsx b/components/admin/admin-header.tsx deleted file mode 100644 index 61d89f6..0000000 --- a/components/admin/admin-header.tsx +++ /dev/null @@ -1,23 +0,0 @@ -"use client"; - -import { Building2 } from "lucide-react"; - -export function AdminHeader() { - return ( -
-
- - - Administration - -
- -

Dashboard Managérial

- -

- Vue d'ensemble des compétences par équipe et direction pour pilotage - stratégique -

-
- ); -} diff --git a/components/admin/index.ts b/components/admin/index.ts index 6aede39..7fd7641 100644 --- a/components/admin/index.ts +++ b/components/admin/index.ts @@ -1,17 +1,15 @@ -export { TeamStatsCard } from "./team-stats-card"; -export { DirectionOverview } from "./direction-overview"; -export { MultiSelectFilter } from "./multi-select-filter"; -export { AdminClientWrapper } from "./admin-client-wrapper"; -export { TeamDetailClientWrapper } from "./team-detail-client-wrapper"; -export { AdminHeader } from "./admin-header"; -export { AdminOverviewCards } from "./admin-overview-cards"; -export { AdminFilters } from "./admin-filters"; -export { AdminContentTabs } from "./admin-content-tabs"; -export { TeamDetailHeader } from "./team-detail-header"; -export { TeamMetricsCards } from "./team-metrics-cards"; -export { TeamDetailTabs } from "./team-detail-tabs"; -export { TeamOverviewTab } from "./team-overview-tab"; -export { TeamSkillsTab } from "./team-skills-tab"; -export { TeamMembersTab } from "./team-members-tab"; -export { TeamInsightsTab } from "./team-insights-tab"; -export { TeamMemberModal } from "./team-member-modal"; +// Composants de vue d'ensemble +export * from "./overview"; + +// Nouveaux composants de gestion +export * from "./layout"; +export * from "./management/pages"; + +// Composants réutilisables pour l'arborescence +export * from "./management"; + +// Composants de détail des équipes +export * from "./team-detail"; + +// Composants utilitaires +export * from "./utils"; diff --git a/components/admin/layout/index.ts b/components/admin/layout/index.ts new file mode 100644 index 0000000..32d842b --- /dev/null +++ b/components/admin/layout/index.ts @@ -0,0 +1,3 @@ +// Composants de layout et navigation +export { ManageAdminClientWrapper } from "./manage-admin-client-wrapper"; +export { ManageContentTabs } from "./manage-content-tabs"; diff --git a/components/admin/layout/manage-admin-client-wrapper.tsx b/components/admin/layout/manage-admin-client-wrapper.tsx new file mode 100644 index 0000000..a11b463 --- /dev/null +++ b/components/admin/layout/manage-admin-client-wrapper.tsx @@ -0,0 +1,43 @@ +"use client"; + +import { useState } from "react"; +import { Team, SkillCategory } from "@/lib/types"; +import { TeamStats, DirectionStats } from "@/services/admin-service"; +import { AdminHeader } from "../utils/admin-header"; +import { ManageContentTabs } from "./manage-content-tabs"; + +interface ManageAdminClientWrapperProps { + teams: Team[]; + skillCategories: SkillCategory[]; + teamStats: TeamStats[]; + directionStats: DirectionStats[]; +} + +export function ManageAdminClientWrapper({ + teams, + skillCategories, + teamStats, + directionStats, +}: ManageAdminClientWrapperProps) { + return ( +
+ {/* Background Effects */} +
+
+
+ +
+ {/* Header */} + + + {/* Main Content Tabs */} + +
+
+ ); +} diff --git a/components/admin/layout/manage-content-tabs.tsx b/components/admin/layout/manage-content-tabs.tsx new file mode 100644 index 0000000..83f773c --- /dev/null +++ b/components/admin/layout/manage-content-tabs.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Code2, Users } from "lucide-react"; +import { Team, SkillCategory } from "@/lib/types"; +import { TeamStats, DirectionStats } from "@/services/admin-service"; +import { SkillsManagement } from "../management/pages/skills-management"; +import { TeamsManagement } from "../management/pages/teams-management"; + +interface ManageContentTabsProps { + teams: Team[]; + skillCategories: SkillCategory[]; + teamStats: TeamStats[]; + directionStats: DirectionStats[]; +} + +export function ManageContentTabs({ + teams, + skillCategories, + teamStats, + directionStats, +}: ManageContentTabsProps) { + return ( + +
+ + + + Gestion des Skills + + + + Gestion des Teams + + +
+ + + + + + + + +
+ ); +} diff --git a/components/admin/management/index.ts b/components/admin/management/index.ts new file mode 100644 index 0000000..1cf9a19 --- /dev/null +++ b/components/admin/management/index.ts @@ -0,0 +1,6 @@ +// Composants réutilisables pour la vue arborescente +export { TreeViewContainer } from "./tree-view-container"; +export { TreeCategoryHeader } from "./tree-category-header"; +export { TreeItemRow } from "./tree-item-row"; +export { TreeSearchControls } from "./tree-search-controls"; +export { TeamMetrics } from "./team-metrics"; diff --git a/components/admin/management/pages/index.ts b/components/admin/management/pages/index.ts new file mode 100644 index 0000000..5a86504 --- /dev/null +++ b/components/admin/management/pages/index.ts @@ -0,0 +1,3 @@ +// Composants de pages de gestion +export { SkillsManagement } from "./skills-management"; +export { TeamsManagement } from "./teams-management"; diff --git a/components/admin/management/pages/skills-management.tsx b/components/admin/management/pages/skills-management.tsx new file mode 100644 index 0000000..9a160a2 --- /dev/null +++ b/components/admin/management/pages/skills-management.tsx @@ -0,0 +1,596 @@ +"use client"; + +import { useState, useEffect, useMemo } from "react"; +import { + Plus, + Edit, + Trash2, + Code2, + Search, + Folder, + FolderOpen, + ChevronRight, + ChevronDown, + Palette, + Database, + Cloud, + Shield, + Smartphone, + Layers, +} from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Textarea } from "@/components/ui/textarea"; +import { useToast } from "@/hooks/use-toast"; +import { SkillCategory, Team } from "@/lib/types"; +import { + AdminManagementService, + Skill, +} from "@/services/admin-management-service"; +import { TechIcon } from "@/components/icons/tech-icon"; +import { + TreeViewContainer, + TreeCategoryHeader, + TreeItemRow, + TreeSearchControls, +} from "@/components/admin"; + +interface SkillsManagementProps { + skillCategories: SkillCategory[]; + teams: Team[]; +} + +interface SkillFormData { + name: string; + categoryId: string; + description: string; + icon: string; +} + +export function SkillsManagement({ + skillCategories, + teams, +}: SkillsManagementProps) { + const [searchTerm, setSearchTerm] = useState(""); + const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); + const [isEditDialogOpen, setIsEditDialogOpen] = useState(false); + const [editingSkill, setEditingSkill] = useState(null); + const [skillFormData, setSkillFormData] = useState({ + name: "", + categoryId: "", + description: "", + icon: "", + }); + const { toast } = useToast(); + + // État des skills + const [skills, setSkills] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + // État pour les catégories ouvertes/fermées + const [expandedCategories, setExpandedCategories] = useState>( + new Set() + ); + + // Grouper les skills par catégorie et filtrer en fonction de la recherche + const filteredSkillsByCategory = useMemo(() => { + // Grouper les skills par catégorie + const skillsByCategory = skills.reduce((acc, skill) => { + if (!acc[skill.category]) { + acc[skill.category] = []; + } + acc[skill.category].push(skill); + return acc; + }, {} as Record); + + // Filtrer les skills en fonction de la recherche + return Object.entries(skillsByCategory).reduce( + (acc, [category, categorySkills]) => { + const filteredSkills = categorySkills.filter((skill) => { + const matchesSearch = + skill.name.toLowerCase().includes(searchTerm.toLowerCase()) || + (skill.description && + skill.description + .toLowerCase() + .includes(searchTerm.toLowerCase())); + return matchesSearch; + }); + + if (filteredSkills.length > 0) { + acc[category] = filteredSkills; + } + return acc; + }, + {} as Record + ); + }, [skills, searchTerm]); + + // Fonctions pour gérer l'expansion des catégories + const toggleCategory = useMemo( + () => (category: string) => { + setExpandedCategories((prev) => { + const newExpanded = new Set(prev); + if (newExpanded.has(category)) { + newExpanded.delete(category); + } else { + newExpanded.add(category); + } + return newExpanded; + }); + }, + [] + ); + + const expandAll = useMemo( + () => () => { + setExpandedCategories(new Set(Object.keys(filteredSkillsByCategory))); + }, + [filteredSkillsByCategory] + ); + + const collapseAll = useMemo( + () => () => { + setExpandedCategories(new Set()); + }, + [] + ); + + // Charger les skills depuis l'API + const fetchSkills = async () => { + try { + setIsLoading(true); + const skillsData = await AdminManagementService.getSkills(); + setSkills(skillsData); + } catch (error) { + console.error("Error fetching skills:", error); + toast({ + title: "Erreur", + description: "Impossible de charger les skills", + variant: "destructive", + }); + } finally { + setIsLoading(false); + } + }; + + // Charger les skills au montage du composant + useEffect(() => { + fetchSkills(); + }, []); + + // Ouvrir automatiquement les catégories qui contiennent des résultats lors de la recherche + useEffect(() => { + if (searchTerm.trim()) { + const categoriesWithResults = Object.keys(filteredSkillsByCategory); + setExpandedCategories(new Set(categoriesWithResults)); + } else { + // Si pas de recherche, fermer toutes les catégories + setExpandedCategories(new Set()); + } + }, [searchTerm, filteredSkillsByCategory]); + + const handleCreateSkill = async () => { + if (!skillFormData.name || !skillFormData.categoryId) { + toast({ + title: "Erreur", + description: "Veuillez remplir tous les champs obligatoires", + variant: "destructive", + }); + return; + } + + try { + const categoryIndex = parseInt(skillFormData.categoryId); + const category = skillCategories[categoryIndex]; + + const skillData = { + ...skillFormData, + category: category.category, + }; + + const newSkill = await AdminManagementService.createSkill(skillData); + setSkills([...skills, newSkill]); + setSkillFormData({ name: "", categoryId: "", description: "", icon: "" }); + setIsCreateDialogOpen(false); + + toast({ + title: "Succès", + description: "Skill créée avec succès", + }); + } catch (error: any) { + toast({ + title: "Erreur", + description: error.message || "Erreur lors de la création de la skill", + variant: "destructive", + }); + } + }; + + const handleEditSkill = (skill: any) => { + setEditingSkill(skill); + const categoryIndex = skillCategories.findIndex( + (cat) => cat.category === skill.category + ); + setSkillFormData({ + name: skill.name, + categoryId: categoryIndex !== -1 ? categoryIndex.toString() : "", + description: skill.description, + icon: skill.icon, + }); + setIsEditDialogOpen(true); + }; + + const handleUpdateSkill = async () => { + if (!editingSkill || !skillFormData.name || !skillFormData.categoryId) { + toast({ + title: "Erreur", + description: "Veuillez remplir tous les champs obligatoires", + variant: "destructive", + }); + return; + } + + try { + const categoryIndex = parseInt(skillFormData.categoryId); + const category = skillCategories[categoryIndex]; + + const skillData = { + id: editingSkill.id, + ...skillFormData, + category: category.category, + usageCount: editingSkill.usageCount, + }; + + const updatedSkill = await AdminManagementService.updateSkill(skillData); + + const updatedSkills = skills.map((skill) => + skill.id === editingSkill.id ? updatedSkill : skill + ); + + setSkills(updatedSkills); + setIsEditDialogOpen(false); + setEditingSkill(null); + setSkillFormData({ name: "", categoryId: "", description: "", icon: "" }); + + toast({ + title: "Succès", + description: "Skill mise à jour avec succès", + }); + } catch (error: any) { + toast({ + title: "Erreur", + description: + error.message || "Erreur lors de la mise à jour de la skill", + variant: "destructive", + }); + } + }; + + const handleDeleteSkill = async (skillId: string) => { + if ( + confirm( + "Êtes-vous sûr de vouloir supprimer cette skill ? Cette action est irréversible." + ) + ) { + try { + await AdminManagementService.deleteSkill(skillId); + setSkills(skills.filter((s) => s.id !== skillId)); + toast({ + title: "Succès", + description: "Skill supprimée avec succès", + }); + } catch (error: any) { + toast({ + title: "Erreur", + description: + error.message || "Erreur lors de la suppression de la skill", + variant: "destructive", + }); + } + } + }; + + const resetForm = () => { + setSkillFormData({ name: "", categoryId: "", description: "", icon: "" }); + }; + + // Fonction pour obtenir l'icône de la catégorie + const getCategoryIcon = (category: string) => { + const categoryName = category.toLowerCase(); + if (categoryName.includes("frontend") || categoryName.includes("front")) + return Code2; + if (categoryName.includes("backend") || categoryName.includes("back")) + return Layers; + if ( + categoryName.includes("design") || + categoryName.includes("ui") || + categoryName.includes("ux") + ) + return Palette; + if (categoryName.includes("data") || categoryName.includes("database")) + return Database; + if (categoryName.includes("cloud") || categoryName.includes("devops")) + return Cloud; + if (categoryName.includes("security") || categoryName.includes("securité")) + return Shield; + if ( + categoryName.includes("mobile") || + categoryName.includes("android") || + categoryName.includes("ios") + ) + return Smartphone; + return Code2; // Par défaut + }; + + return ( +
+ {/* Header */} +
+
+

Gestion des Skills

+

+ Créez, modifiez et supprimez les skills de votre organisation +

+
+ + + + + + + Créer une nouvelle skill + +
+
+ + + setSkillFormData({ ...skillFormData, name: e.target.value }) + } + placeholder="Ex: React, Node.js, PostgreSQL" + /> +
+
+ + +
+
+ +