feat: CRUD admin for skills and teams

This commit is contained in:
Julien Froidefond
2025-08-22 08:56:02 +02:00
parent 514b33870b
commit e314a96fae
43 changed files with 2516 additions and 179 deletions

54
TODO.md Normal file
View File

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

39
app/admin/manage/page.tsx Normal file
View File

@@ -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 (
<ManageAdminClientWrapper
teams={adminData.teams}
skillCategories={adminData.skillCategories}
teamStats={adminData.teamStats}
directionStats={adminData.directionStats}
/>
);
} catch (error) {
console.error("Failed to load admin data:", error);
return (
<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
</div>
</div>
</div>
);
}
}

View File

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

View File

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

117
components/admin/README.md Normal file
View File

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

View File

@@ -1,23 +0,0 @@
"use client";
import { Building2 } from "lucide-react";
export function AdminHeader() {
return (
<div className="text-center space-y-4 mb-12">
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-white/5 border border-white/10 backdrop-blur-sm">
<Building2 className="h-4 w-4 text-blue-400" />
<span className="text-sm font-medium text-slate-200">
Administration
</span>
</div>
<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
stratégique
</p>
</div>
);
}

View File

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

View File

@@ -0,0 +1,3 @@
// Composants de layout et navigation
export { ManageAdminClientWrapper } from "./manage-admin-client-wrapper";
export { ManageContentTabs } from "./manage-content-tabs";

View File

@@ -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 (
<div className="min-h-screen bg-gradient-to-br from-slate-950 via-slate-900 to-slate-950 relative overflow-hidden">
{/* Background Effects */}
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-blue-900/20 via-slate-900 to-slate-950" />
<div className="absolute inset-0 bg-grid-white/5 bg-[size:50px_50px]" />
<div className="absolute inset-0 bg-gradient-to-t from-slate-950 via-transparent to-transparent" />
<div className="relative z-10 container mx-auto px-6 py-6 max-w-7xl space-y-6">
{/* Header */}
<AdminHeader />
{/* Main Content Tabs */}
<ManageContentTabs
teams={teams}
skillCategories={skillCategories}
teamStats={teamStats}
directionStats={directionStats}
/>
</div>
</div>
);
}

View File

@@ -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 (
<Tabs defaultValue="skills" className="w-full">
<div className="bg-white/5 backdrop-blur-sm border border-white/10 rounded-2xl p-1 mb-6 w-fit mx-auto">
<TabsList className="grid w-full grid-cols-2 bg-transparent border-0">
<TabsTrigger
value="skills"
className="data-[state=active]:bg-white/20 data-[state=active]:text-white text-slate-400 hover:text-white transition-colors"
>
<Code2 className="w-4 h-4 mr-2" />
Gestion des Skills
</TabsTrigger>
<TabsTrigger
value="teams"
className="data-[state=active]:bg-white/20 data-[state=active]:text-white text-slate-400 hover:text-white transition-colors"
>
<Users className="w-4 h-4 mr-2" />
Gestion des Teams
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="skills" className="space-y-4">
<SkillsManagement skillCategories={skillCategories} teams={teams} />
</TabsContent>
<TabsContent value="teams" className="space-y-4">
<TeamsManagement
teams={teams}
teamStats={teamStats}
skillCategories={skillCategories}
/>
</TabsContent>
</Tabs>
);
}

View File

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

View File

@@ -0,0 +1,3 @@
// Composants de pages de gestion
export { SkillsManagement } from "./skills-management";
export { TeamsManagement } from "./teams-management";

View File

@@ -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<any>(null);
const [skillFormData, setSkillFormData] = useState<SkillFormData>({
name: "",
categoryId: "",
description: "",
icon: "",
});
const { toast } = useToast();
// État des skills
const [skills, setSkills] = useState<Skill[]>([]);
const [isLoading, setIsLoading] = useState(true);
// État pour les catégories ouvertes/fermées
const [expandedCategories, setExpandedCategories] = useState<Set<string>>(
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<string, Skill[]>);
// 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<string, Skill[]>
);
}, [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 (
<div className="space-y-4">
{/* Header */}
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3">
<div>
<h2 className="text-2xl font-bold text-white">Gestion des Skills</h2>
<p className="text-slate-400">
Créez, modifiez et supprimez les skills de votre organisation
</p>
</div>
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<DialogTrigger asChild>
<Button onClick={resetForm}>
<Plus className="w-4 h-4 mr-2" />
Nouvelle Skill
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Créer une nouvelle skill</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="skill-name">Nom de la skill *</Label>
<Input
id="skill-name"
value={skillFormData.name}
onChange={(e) =>
setSkillFormData({ ...skillFormData, name: e.target.value })
}
placeholder="Ex: React, Node.js, PostgreSQL"
/>
</div>
<div>
<Label htmlFor="skill-category">Catégorie *</Label>
<Select
value={skillFormData.categoryId}
onValueChange={(value) =>
setSkillFormData({ ...skillFormData, categoryId: value })
}
>
<SelectTrigger>
<SelectValue placeholder="Sélectionner une catégorie" />
</SelectTrigger>
<SelectContent>
{skillCategories.map((category, index) => (
<SelectItem key={index} value={index.toString()}>
{category.category}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="skill-description">Description</Label>
<Textarea
id="skill-description"
value={skillFormData.description}
onChange={(e) =>
setSkillFormData({
...skillFormData,
description: e.target.value,
})
}
placeholder="Description de la skill..."
rows={3}
/>
</div>
<div>
<Label htmlFor="skill-icon">Icône</Label>
<Input
id="skill-icon"
value={skillFormData.icon}
onChange={(e) =>
setSkillFormData({ ...skillFormData, icon: e.target.value })
}
placeholder="Ex: react, nodejs, postgresql"
/>
</div>
<div className="flex justify-end gap-2 pt-4">
<Button
variant="outline"
onClick={() => setIsCreateDialogOpen(false)}
>
Annuler
</Button>
<Button onClick={handleCreateSkill}>Créer</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
{/* Filtres et contrôles */}
<TreeSearchControls
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
onExpandAll={expandAll}
onCollapseAll={collapseAll}
placeholder="Rechercher une skill..."
/>
{/* Vue arborescente des Skills */}
<TreeViewContainer
isLoading={isLoading}
loadingMessage="Chargement des skills..."
hasContent={Object.keys(filteredSkillsByCategory).length > 0}
emptyState={
<div className="text-center py-8">
<Code2 className="w-10 h-10 text-slate-500 mx-auto mb-3" />
<h3 className="text-base font-medium text-slate-400 mb-1">
{searchTerm ? "Aucune skill trouvée" : "Aucune skill"}
</h3>
<p className="text-slate-500 text-sm">
{searchTerm
? "Essayez de modifier vos critères de recherche"
: "Commencez par créer votre première skill"}
</p>
</div>
}
>
{Object.entries(filteredSkillsByCategory).map(
([category, categorySkills], index) => (
<div key={category}>
<TreeCategoryHeader
category={category}
isExpanded={expandedCategories.has(category)}
onToggle={() => toggleCategory(category)}
icon={(() => {
const IconComponent = getCategoryIcon(category);
return <IconComponent className="w-5 h-5 text-blue-400" />;
})()}
itemCount={categorySkills.length}
itemLabel="skill"
showSeparator={index > 0}
/>
{/* Liste des skills de la catégorie */}
{expandedCategories.has(category) && (
<div className="bg-slate-950/30">
{categorySkills.map((skill, skillIndex) => (
<TreeItemRow
key={skill.id}
icon={
<div className="flex items-center gap-3">
<TechIcon
iconName={skill.icon}
className="w-5 h-5 text-green-400"
fallbackText={skill.name}
/>
<div className="p-1 bg-green-500/20 border border-green-500/30 rounded text-xs font-mono text-green-400 min-w-[3rem] text-center shrink-0">
{skill.icon || "?"}
</div>
</div>
}
title={skill.name}
subtitle={skill.description}
badges={[
{
text: `${skill.usageCount} util.`,
variant: "outline",
},
]}
onEdit={() => handleEditSkill(skill)}
onDelete={() => handleDeleteSkill(skill.id)}
canDelete={skill.usageCount === 0}
showSeparator={skillIndex > 0}
/>
))}
</div>
)}
</div>
)
)}
</TreeViewContainer>
{/* Dialog d'édition */}
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Modifier la skill</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="edit-skill-name">Nom de la skill *</Label>
<Input
id="edit-skill-name"
value={skillFormData.name}
onChange={(e) =>
setSkillFormData({ ...skillFormData, name: e.target.value })
}
placeholder="Ex: React, Node.js, PostgreSQL"
/>
</div>
<div>
<Label htmlFor="edit-skill-category">Catégorie *</Label>
<Select
value={skillFormData.categoryId}
onValueChange={(value) =>
setSkillFormData({ ...skillFormData, categoryId: value })
}
>
<SelectTrigger>
<SelectValue placeholder="Sélectionner une catégorie" />
</SelectTrigger>
<SelectContent>
{skillCategories.map((category, index) => (
<SelectItem key={index} value={index.toString()}>
{category.category}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="edit-skill-description">Description</Label>
<Textarea
id="edit-skill-description"
value={skillFormData.description}
onChange={(e) =>
setSkillFormData({
...skillFormData,
description: e.target.value,
})
}
placeholder="Description de la skill..."
rows={3}
/>
</div>
<div>
<Label htmlFor="edit-skill-icon">Icône</Label>
<Input
id="edit-skill-icon"
value={skillFormData.icon}
onChange={(e) =>
setSkillFormData({ ...skillFormData, icon: e.target.value })
}
placeholder="Ex: react, nodejs, postgresql"
/>
</div>
<div className="flex justify-end gap-2 pt-4">
<Button
variant="outline"
onClick={() => setIsEditDialogOpen(false)}
>
Annuler
</Button>
<Button onClick={handleUpdateSkill}>Mettre à jour</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,547 @@
"use client";
import { useState, useEffect, useMemo } from "react";
import { Plus, Edit, Trash2, Users, Search, Building2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Card, CardContent } 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 { useToast } from "@/hooks/use-toast";
import { SkillCategory, Team as TeamType } from "@/lib/types";
import { TeamStats } from "@/services/admin-service";
import {
AdminManagementService,
Team,
} from "@/services/admin-management-service";
import {
TreeViewContainer,
TreeCategoryHeader,
TreeItemRow,
TreeSearchControls,
TeamMetrics,
} from "@/components/admin";
interface TeamsManagementProps {
teams: TeamType[];
teamStats: TeamStats[];
skillCategories: SkillCategory[];
}
interface TeamFormData {
name: string;
direction: string;
}
export function TeamsManagement({
teams,
teamStats,
skillCategories,
}: TeamsManagementProps) {
const [searchTerm, setSearchTerm] = useState("");
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
const [editingTeam, setEditingTeam] = useState<any>(null);
const [teamFormData, setTeamFormData] = useState<TeamFormData>({
name: "",
direction: "",
});
const { toast } = useToast();
// État pour les directions ouvertes/fermées
const [expandedDirections, setExpandedDirections] = useState<Set<string>>(
new Set()
);
// État pour gérer la liste des équipes
// Grouper les teams par direction et filtrer en fonction de la recherche
const filteredTeamsByDirection = useMemo(() => {
// Grouper les teams par direction
const teamsByDirection = teams.reduce((acc, team) => {
if (!acc[team.direction]) {
acc[team.direction] = [];
}
acc[team.direction].push(team);
return acc;
}, {} as Record<string, TeamType[]>);
// Filtrer les teams en fonction de la recherche
return Object.entries(teamsByDirection).reduce(
(acc, [direction, directionTeams]) => {
const filteredTeams = directionTeams.filter((team) => {
const matchesSearch = team.name
.toLowerCase()
.includes(searchTerm.toLowerCase());
return matchesSearch;
});
if (filteredTeams.length > 0) {
acc[direction] = filteredTeams;
}
return acc;
},
{} as Record<string, TeamType[]>
);
}, [teams, searchTerm]);
// Fonctions pour gérer l'expansion des directions
const toggleDirection = useMemo(
() => (direction: string) => {
setExpandedDirections((prev) => {
const newExpanded = new Set(prev);
if (newExpanded.has(direction)) {
newExpanded.delete(direction);
} else {
newExpanded.add(direction);
}
return newExpanded;
});
},
[]
);
const expandAll = useMemo(
() => () => {
setExpandedDirections(new Set(Object.keys(filteredTeamsByDirection)));
},
[filteredTeamsByDirection]
);
const collapseAll = useMemo(
() => () => {
setExpandedDirections(new Set());
},
[]
);
const getTeamStats = (teamId: string) => {
return teamStats.find((stats) => stats.teamId === teamId);
};
// Charger les teams depuis l'API
const fetchTeams = async () => {
try {
const teamsData = await AdminManagementService.getTeams();
// Note: on garde les teams existantes pour la compatibilité
// Les nouvelles teams créées via l'API seront visibles après rafraîchissement
} catch (error) {
console.error("Error fetching teams:", error);
toast({
title: "Erreur",
description: "Impossible de charger les teams",
variant: "destructive",
});
}
};
// Charger les teams au montage du composant
useEffect(() => {
fetchTeams();
}, []);
// Ouvrir automatiquement les directions qui contiennent des résultats lors de la recherche
useEffect(() => {
if (searchTerm.trim()) {
const directionsWithResults = Object.keys(filteredTeamsByDirection);
setExpandedDirections(new Set(directionsWithResults));
} else {
// Si pas de recherche, fermer toutes les directions
setExpandedDirections(new Set());
}
}, [searchTerm, filteredTeamsByDirection]);
const handleCreateTeam = async () => {
if (!teamFormData.name || !teamFormData.direction) {
toast({
title: "Erreur",
description: "Veuillez remplir tous les champs obligatoires",
variant: "destructive",
});
return;
}
try {
const newTeam = await AdminManagementService.createTeam(teamFormData);
toast({
title: "Succès",
description: "Équipe créée avec succès",
});
setTeamFormData({ name: "", direction: "" });
setIsCreateDialogOpen(false);
// Rafraîchir la page pour voir les changements
window.location.reload();
} catch (error: any) {
toast({
title: "Erreur",
description: error.message || "Erreur lors de la création de l'équipe",
variant: "destructive",
});
}
};
const handleEditTeam = (team: any) => {
setEditingTeam(team);
setTeamFormData({
name: team.name,
direction: team.direction,
});
setIsEditDialogOpen(true);
};
const handleUpdateTeam = async () => {
if (!editingTeam || !teamFormData.name || !teamFormData.direction) {
toast({
title: "Erreur",
description: "Veuillez remplir tous les champs obligatoires",
variant: "destructive",
});
return;
}
try {
await AdminManagementService.updateTeam({
id: editingTeam.id,
...teamFormData,
memberCount: editingTeam.memberCount || 0,
});
toast({
title: "Succès",
description: "Équipe mise à jour avec succès",
});
setIsEditDialogOpen(false);
setEditingTeam(null);
setTeamFormData({ name: "", direction: "" });
// Rafraîchir la page pour voir les changements
window.location.reload();
} catch (error: any) {
toast({
title: "Erreur",
description:
error.message || "Erreur lors de la mise à jour de l'équipe",
variant: "destructive",
});
}
};
const handleDeleteTeam = async (teamId: string) => {
const team = teams.find((t) => t.id === teamId);
const stats = getTeamStats(teamId);
if (stats && stats.totalMembers > 0) {
toast({
title: "Erreur",
description:
"Impossible de supprimer une équipe qui contient des membres",
variant: "destructive",
});
return;
}
if (
confirm(
`Êtes-vous sûr de vouloir supprimer l'équipe "${team?.name}" ? Cette action est irréversible.`
)
) {
try {
await AdminManagementService.deleteTeam(teamId);
toast({
title: "Succès",
description: "Équipe supprimée avec succès",
});
// Rafraîchir la page pour voir les changements
window.location.reload();
} catch (error: any) {
toast({
title: "Erreur",
description:
error.message || "Erreur lors de la suppression de l'équipe",
variant: "destructive",
});
}
}
};
const handleDeleteDirection = async (direction: string) => {
// Vérifier si des équipes de cette direction ont des membres
const teamsInDirection = teams.filter(
(team) => team.direction === direction
);
const hasMembers = teamsInDirection.some((team) => {
const stats = getTeamStats(team.id);
return stats && stats.totalMembers > 0;
});
if (hasMembers) {
toast({
title: "Erreur",
description: `Impossible de supprimer la direction "${direction}" car certaines équipes ont des membres`,
variant: "destructive",
});
return;
}
if (
confirm(
`Êtes-vous sûr de vouloir supprimer la direction "${direction}" et TOUTES ses équipes ?\n\n⚠ Cette action est irréversible !\n\nÉquipes qui seront supprimées :\n${teamsInDirection
.map((t) => `${t.name}`)
.join("\n")}`
)
) {
try {
await AdminManagementService.deleteDirection(direction);
toast({
title: "Succès",
description: `Direction "${direction}" et toutes ses équipes supprimées avec succès`,
variant: "default",
});
// Rafraîchir la page pour voir les changements
window.location.reload();
} catch (error: any) {
toast({
title: "Erreur",
description:
error.message || "Erreur lors de la suppression de la direction",
variant: "destructive",
});
}
}
};
const resetForm = () => {
setTeamFormData({ name: "", direction: "" });
};
// Extraire les directions uniques pour les formulaires
const directions = Array.from(new Set(teams.map((team) => team.direction)));
return (
<div className="space-y-4">
{/* Header */}
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-3">
<div>
<h2 className="text-2xl font-bold text-white">Gestion des Teams</h2>
<p className="text-slate-400">
Créez, modifiez et supprimez les équipes de votre organisation
</p>
</div>
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
<DialogTrigger asChild>
<Button onClick={resetForm}>
<Plus className="w-4 h-4 mr-2" />
Nouvelle Équipe
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Créer une nouvelle équipe</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="team-name">Nom de l'équipe *</Label>
<Input
id="team-name"
value={teamFormData.name}
onChange={(e) =>
setTeamFormData({ ...teamFormData, name: e.target.value })
}
placeholder="Ex: Équipe Frontend, Équipe Backend"
/>
</div>
<div>
<Label htmlFor="team-direction">Direction *</Label>
<Select
value={teamFormData.direction}
onValueChange={(value) =>
setTeamFormData({ ...teamFormData, direction: value })
}
>
<SelectTrigger>
<SelectValue placeholder="Sélectionner une direction" />
</SelectTrigger>
<SelectContent>
{directions.map((direction) => (
<SelectItem key={direction} value={direction}>
{direction}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex justify-end gap-2 pt-4">
<Button
variant="outline"
onClick={() => setIsCreateDialogOpen(false)}
>
Annuler
</Button>
<Button onClick={handleCreateTeam}>Créer</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
{/* Filtres et contrôles */}
<TreeSearchControls
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
onExpandAll={expandAll}
onCollapseAll={collapseAll}
placeholder="Rechercher une équipe..."
/>
{/* Vue arborescente des Teams */}
<TreeViewContainer
hasContent={Object.keys(filteredTeamsByDirection).length > 0}
emptyState={
<div className="text-center py-8">
<Building2 className="w-10 h-10 text-slate-500 mx-auto mb-3" />
<h3 className="text-base font-medium text-slate-400 mb-1">
{searchTerm ? "Aucune équipe trouvée" : "Aucune équipe"}
</h3>
<p className="text-sm text-slate-500">
{searchTerm
? "Essayez de modifier vos critères de recherche"
: "Commencez par créer votre première équipe"}
</p>
</div>
}
>
{Object.entries(filteredTeamsByDirection).map(
([direction, directionTeams], index) => (
<div key={direction}>
<TreeCategoryHeader
category={direction}
isExpanded={expandedDirections.has(direction)}
onToggle={() => toggleDirection(direction)}
icon={<Building2 className="w-5 h-5 text-blue-400" />}
itemCount={directionTeams.length}
itemLabel="équipe"
showSeparator={index > 0}
onDelete={() => handleDeleteDirection(direction)}
canDelete={true}
isDirection={true}
/>
{/* Liste des teams de la direction */}
{expandedDirections.has(direction) && (
<div className="bg-slate-950/30">
{directionTeams.map((team, teamIndex) => {
const stats = getTeamStats(team.id);
return (
<TreeItemRow
key={team.id}
icon={<Users className="w-5 h-5 text-green-400" />}
title={team.name}
badges={
stats
? [
{
text: `${stats.totalMembers} membres`,
variant: "outline",
},
]
: []
}
onEdit={() => handleEditTeam(team)}
onDelete={() => handleDeleteTeam(team.id)}
canDelete={!stats || stats.totalMembers === 0}
showSeparator={teamIndex > 0}
additionalInfo={
stats ? (
<TeamMetrics
averageSkillLevel={stats.averageSkillLevel}
skillCoverage={stats.skillCoverage}
topSkillsCount={stats.topSkills.length}
totalMembers={stats.totalMembers}
/>
) : (
<p className="text-slate-500 text-xs">
Aucune donnée disponible
</p>
)
}
/>
);
})}
</div>
)}
</div>
)
)}
</TreeViewContainer>
{/* Dialog d'édition */}
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Modifier l'équipe</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div>
<Label htmlFor="edit-team-name">Nom de l'équipe *</Label>
<Input
id="edit-team-name"
value={teamFormData.name}
onChange={(e) =>
setTeamFormData({ ...teamFormData, name: e.target.value })
}
placeholder="Ex: Équipe Frontend, Équipe Backend"
/>
</div>
<div>
<Label htmlFor="edit-team-direction">Direction *</Label>
<Select
value={teamFormData.direction}
onValueChange={(value) =>
setTeamFormData({ ...teamFormData, direction: value })
}
>
<SelectTrigger>
<SelectValue placeholder="Sélectionner une direction" />
</SelectTrigger>
<SelectContent>
{directions.map((direction) => (
<SelectItem key={direction} value={direction}>
{direction}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex justify-end gap-2 pt-4">
<Button
variant="outline"
onClick={() => setIsEditDialogOpen(false)}
>
Annuler
</Button>
<Button onClick={handleUpdateTeam}>Mettre à jour</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,40 @@
"use client";
interface TeamMetricsProps {
averageSkillLevel: number;
skillCoverage: number;
topSkillsCount: number;
totalMembers: number;
}
export function TeamMetrics({
averageSkillLevel,
skillCoverage,
topSkillsCount,
totalMembers,
}: TeamMetricsProps) {
return (
<div className="grid grid-cols-2 md:grid-cols-4 gap-2 text-center">
<div>
<div className="text-xs text-slate-400">Niveau moy.</div>
<div className="text-sm font-semibold text-white">
{averageSkillLevel.toFixed(1)}
</div>
</div>
<div>
<div className="text-xs text-slate-400">Couverture</div>
<div className="text-sm font-semibold text-white">
{skillCoverage.toFixed(0)}%
</div>
</div>
<div>
<div className="text-xs text-slate-400">Top Skills</div>
<div className="text-sm font-semibold text-white">{topSkillsCount}</div>
</div>
<div>
<div className="text-xs text-slate-400">Membres</div>
<div className="text-sm font-semibold text-white">{totalMembers}</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,72 @@
"use client";
import { ChevronRight, ChevronDown, Trash2 } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
interface TreeCategoryHeaderProps {
category: string;
isExpanded: boolean;
onToggle: () => void;
icon?: React.ReactNode;
itemCount: number;
itemLabel: string; // "skill" or "équipe" etc.
showSeparator?: boolean;
onDelete?: () => void;
canDelete?: boolean;
isDirection?: boolean; // Pour différencier les directions des catégories de skills
}
export function TreeCategoryHeader({
category,
isExpanded,
onToggle,
icon,
itemCount,
itemLabel,
showSeparator = false,
onDelete,
canDelete = true,
isDirection = false,
}: TreeCategoryHeaderProps) {
return (
<div>
{/* Séparateur entre catégories (sauf pour la première) */}
{showSeparator && <div className="border-t border-white/5" />}
{/* Header de catégorie */}
<div
className="flex items-center gap-2 p-3 cursor-pointer hover:bg-white/5 transition-colors"
onClick={onToggle}
>
{isExpanded ? (
<ChevronDown className="w-4 h-4 text-slate-400" />
) : (
<ChevronRight className="w-4 h-4 text-slate-400" />
)}
{icon}
<h3 className="text-base font-semibold text-white">{category}</h3>
<div className="flex items-center gap-2 ml-auto">
<Badge variant="outline" className="text-xs px-2 py-0.5">
{itemCount} {itemLabel}
{itemCount > 1 ? "s" : ""}
</Badge>
{isDirection && onDelete && canDelete && (
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation(); // Empêcher l'ouverture/fermeture
onDelete();
}}
className="text-red-400 hover:text-red-300 hover:bg-red-500/20 h-6 w-6 p-0"
title={`Supprimer la direction "${category}" et toutes ses équipes`}
>
<Trash2 className="w-3 h-3" />
</Button>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,95 @@
"use client";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Edit, Trash2 } from "lucide-react";
interface TreeItemRowProps {
icon?: React.ReactNode;
title: string;
subtitle?: string;
badges?: Array<{
text: string;
variant?: "default" | "secondary" | "destructive" | "outline";
className?: string;
}>;
onEdit?: () => void;
onDelete?: () => void;
canDelete?: boolean;
showSeparator?: boolean;
additionalInfo?: React.ReactNode;
}
export function TreeItemRow({
icon,
title,
subtitle,
badges = [],
onEdit,
onDelete,
canDelete = true,
showSeparator = false,
additionalInfo,
}: TreeItemRowProps) {
return (
<div>
{/* Séparateur entre items */}
{showSeparator && <div className="border-t border-white/5 ml-12" />}
<div className="flex items-center justify-between p-2 ml-6 border-l-2 border-slate-700/50 hover:bg-white/5 transition-colors">
<div className="flex items-center gap-3 flex-1 min-w-0">
{icon}
<div className="flex items-center gap-3 flex-1 min-w-0">
<h4 className="text-sm font-medium text-white truncate">{title}</h4>
{badges.map((badge, index) => (
<Badge
key={index}
variant={badge.variant || "outline"}
className={`text-xs px-2 py-0.5 shrink-0 ${
badge.className || ""
}`}
>
{badge.text}
</Badge>
))}
{subtitle && (
<p className="text-slate-400 text-xs truncate flex-1">
{subtitle}
</p>
)}
</div>
</div>
{/* Informations additionnelles (comme les métriques) */}
{additionalInfo && (
<div className="mx-4 shrink-0">{additionalInfo}</div>
)}
{/* Actions */}
<div className="flex items-center gap-1 shrink-0">
{onEdit && (
<Button
variant="ghost"
size="sm"
onClick={onEdit}
className="text-blue-400 hover:text-blue-300 hover:bg-blue-500/20 h-6 w-6 p-0"
>
<Edit className="w-3 h-3" />
</Button>
)}
{onDelete && (
<Button
variant="ghost"
size="sm"
onClick={onDelete}
className="text-red-400 hover:text-red-300 hover:bg-red-500/20 h-6 w-6 p-0"
disabled={!canDelete}
>
<Trash2 className="w-3 h-3" />
</Button>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,53 @@
"use client";
import { Search } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
interface TreeSearchControlsProps {
searchTerm: string;
onSearchChange: (value: string) => void;
onExpandAll: () => void;
onCollapseAll: () => void;
placeholder?: string;
}
export function TreeSearchControls({
searchTerm,
onSearchChange,
onExpandAll,
onCollapseAll,
placeholder = "Rechercher...",
}: TreeSearchControlsProps) {
return (
<div className="flex flex-col sm:flex-row gap-3">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-slate-400 w-4 h-4" />
<Input
placeholder={placeholder}
value={searchTerm}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-10"
/>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={onExpandAll}
className="text-slate-400 hover:text-white"
>
Tout ouvrir
</Button>
<Button
variant="outline"
size="sm"
onClick={onCollapseAll}
className="text-slate-400 hover:text-white"
>
Tout fermer
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,38 @@
"use client";
import { Card, CardContent } from "@/components/ui/card";
interface TreeViewContainerProps {
children: React.ReactNode;
isLoading?: boolean;
loadingMessage?: string;
emptyState?: React.ReactNode;
hasContent?: boolean;
}
export function TreeViewContainer({
children,
isLoading = false,
loadingMessage = "Chargement...",
emptyState,
hasContent = true,
}: TreeViewContainerProps) {
if (isLoading) {
return (
<div className="text-center py-8">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-400 mx-auto mb-3"></div>
<p className="text-slate-400 text-sm">{loadingMessage}</p>
</div>
);
}
if (!hasContent && emptyState) {
return emptyState;
}
return (
<Card className="bg-white/5 border-white/10">
<CardContent className="p-0">{children}</CardContent>
</Card>
);
}

View File

@@ -3,10 +3,10 @@
import { useState } from "react";
import { Team, SkillCategory } from "@/lib/types";
import { TeamStats, DirectionStats } from "@/services/admin-service";
import { TeamDetailModal } from "@/components/admin/team-detail-modal";
import { AdminHeader } from "./admin-header";
import { TeamDetailModal } from "../team-detail/team-detail-modal";
import { AdminHeader } from "../utils/admin-header";
import { AdminOverviewCards } from "./admin-overview-cards";
import { AdminFilters } from "./admin-filters";
import { AdminFilters } from "../utils/admin-filters";
import { AdminContentTabs } from "./admin-content-tabs";
interface AdminClientWrapperProps {

View File

@@ -5,7 +5,7 @@ import {
TeamStatsCard,
getSkillLevelLabel,
getSkillLevelColor,
} from "./team-stats-card";
} from "../team-detail/team-stats-card";
interface DirectionOverviewProps {
direction: string;

View File

@@ -0,0 +1,5 @@
// Composants de vue d'ensemble
export { AdminClientWrapper } from "./admin-client-wrapper";
export { AdminContentTabs } from "./admin-content-tabs";
export { AdminOverviewCards } from "./admin-overview-cards";
export { DirectionOverview } from "./direction-overview";

View File

@@ -0,0 +1,12 @@
// Composants de détail des équipes
export { TeamDetailClientWrapper } from "./team-detail-client-wrapper";
export { TeamDetailHeader } from "./team-detail-header";
export { TeamDetailTabs } from "./team-detail-tabs";
export { TeamDetailModal } from "./team-detail-modal";
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";
export { TeamMetricsCards } from "./team-metrics-cards";
export { TeamStatsCard } from "./team-stats-card";

View File

@@ -0,0 +1,46 @@
"use client";
import { Building2, Settings } from "lucide-react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
export function AdminHeader() {
return (
<div className="text-center space-y-4 mb-12">
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full bg-white/5 border border-white/10 backdrop-blur-sm">
<Building2 className="h-4 w-4 text-blue-400" />
<span className="text-sm font-medium text-slate-200">
Administration
</span>
</div>
<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
stratégique
</p>
<div className="flex justify-center gap-4 pt-4">
<Link href="/admin">
<Button
variant="outline"
className="border-white/20 text-white hover:bg-white/10"
>
<Building2 className="w-4 h-4 mr-2" />
Vue d'ensemble
</Button>
</Link>
<Link href="/admin/manage">
<Button
variant="outline"
className="border-white/20 text-white hover:bg-white/10"
>
<Settings className="w-4 h-4 mr-2" />
Gestion
</Button>
</Link>
</div>
</div>
);
}

View File

@@ -0,0 +1,4 @@
// Composants utilitaires
export { AdminHeader } from "./admin-header";
export { AdminFilters } from "./admin-filters";
export { MultiSelectFilter } from "./multi-select-filter";

View File

@@ -10,16 +10,6 @@
"name": "FFM Pilotage",
"direction": "DIR IT LOGISTIQUE"
},
{
"id": "projets-pro-obsolete",
"name": "Projets PRO (Obsolete)",
"direction": "DIR IT Partenaires"
},
{
"id": "dir-metier---octopia",
"name": "Dir métier - Octopia",
"direction": "Pseudo directions métier"
},
{
"id": "ct-find",
"name": "CT Find",
@@ -40,11 +30,6 @@
"name": "Cross Innov",
"direction": "DIR IT COMMERCE"
},
{
"id": "dir-metier---strategie",
"name": "Dir métier - Stratégie",
"direction": "Pseudo directions métier"
},
{
"id": "projet-cross-canal",
"name": "Projet Cross-Canal",
@@ -60,11 +45,6 @@
"name": "Front Magento",
"direction": "DIR IT MKP CORE MNGT"
},
{
"id": "projets-transverses-mkpachat",
"name": "Projets transverses MKP/Achat",
"direction": "DIR IT Partenaires"
},
{
"id": "dip-platform-sre",
"name": "DIP-Platform-SRE",
@@ -95,21 +75,6 @@
"name": "Equipe Muraille de Chine",
"direction": "DIR IT INFRA PROD"
},
{
"id": "pilotage-dir-it-partenaires",
"name": "Pilotage Dir IT Partenaires",
"direction": "DIR IT Partenaires"
},
{
"id": "dir-metier---logistique",
"name": "Dir métier - Logistique",
"direction": "Pseudo directions métier"
},
{
"id": "dir-metier---partenaires",
"name": "Dir métier - Partenaires",
"direction": "Pseudo directions métier"
},
{
"id": "dip-ops-infogerance",
"name": "DIP-OPS-Infogérance",
@@ -125,11 +90,6 @@
"name": "Fulfilment as a Service (FaaS)",
"direction": "DIR IT LOGISTIQUE"
},
{
"id": "projet-achat",
"name": "Projet Achat",
"direction": "DIR IT Partenaires"
},
{
"id": "b2c-tech",
"name": "B2C TECH",
@@ -140,21 +100,6 @@
"name": "Catalogue",
"direction": "DIR IT Produit"
},
{
"id": "ft-tracking-mkp",
"name": "FT-Tracking MKP",
"direction": "DIR IT Partenaires - Groupe2"
},
{
"id": "catalogue-publication-octopia",
"name": "Catalogue Publication Octopia",
"direction": "DIR IT Partenaires"
},
{
"id": "mkp-livraison-client",
"name": "MKP Livraison client",
"direction": "DIR IT Partenaires - Groupe2"
},
{
"id": "log-appro-fbc-crosscanal",
"name": "Log Appro FBC CrossCanal",
@@ -175,21 +120,11 @@
"name": "ITLog Appro",
"direction": "DIR IT LOGISTIQUE"
},
{
"id": "pilotage-dirit-partenaires",
"name": "Pilotage DirIT Partenaires",
"direction": "DIR IT Partenaires"
},
{
"id": "wallet",
"name": "Wallet",
"direction": "DIR IT FINANCE"
},
{
"id": "dir-metier---regie",
"name": "Dir métier - Régie",
"direction": "Pseudo directions métier"
},
{
"id": "secu-audit",
"name": "Sécu-Audit",
@@ -200,11 +135,6 @@
"name": "FT-Chatbot",
"direction": "DIR IT EXPERIENCE CLIENTS"
},
{
"id": "partenaires-metier---partenaires-g2",
"name": "Partenaires métier - Partenaires G2",
"direction": "DIR IT Partenaires"
},
{
"id": "team-canal-cds",
"name": "Team Canal CDS",
@@ -230,11 +160,6 @@
"name": "Delivery CDS",
"direction": "DIR IT LOGISTIQUE"
},
{
"id": "projet-b2c-services",
"name": "Projet B2C Services",
"direction": "DIR IT Partenaires"
},
{
"id": "flux-produit-octopia",
"name": "Flux produit Octopia",
@@ -310,36 +235,11 @@
"name": "Live Shopping",
"direction": "DIR IT COMMERCE"
},
{
"id": "dir-metier---commerce",
"name": "Dir métier - Commerce",
"direction": "Pseudo directions métier"
},
{
"id": "catalogue-personnalisation-octopia",
"name": "Catalogue Personnalisation Octopia",
"direction": "DIR IT Partenaires"
},
{
"id": "cars-data",
"name": "Cars Data",
"direction": "DIR IT COMMERCE"
},
{
"id": "dir-metier---experience-client",
"name": "Dir métier - Expérience client",
"direction": "Pseudo directions métier"
},
{
"id": "dir-metier---produit",
"name": "Dir métier - Produit",
"direction": "Pseudo directions métier"
},
{
"id": "dir-metier---finance",
"name": "Dir métier - Finance",
"direction": "Pseudo directions métier"
},
{
"id": "etl",
"name": "ETL",
@@ -365,16 +265,6 @@
"name": "Pilotage DirIT Commerce",
"direction": "DIR IT COMMERCE"
},
{
"id": "projet-b2b-services",
"name": "Projet B2B Services",
"direction": "DIR IT Partenaires"
},
{
"id": "export-catalogue-octopia",
"name": "Export catalogue Octopia",
"direction": "DIR IT Partenaires"
},
{
"id": "referentnet",
"name": "referent.net",
@@ -385,11 +275,6 @@
"name": "Equipes Direction SI",
"direction": "Direction SI"
},
{
"id": "dir-metier---rh",
"name": "Dir métier - RH",
"direction": "Pseudo directions métier"
},
{
"id": "ft-servicebusinessfinancier",
"name": "FT-ServiceBusinessFinancier",
@@ -475,16 +360,6 @@
"name": "ServerSide Plugin",
"direction": "DIR IT Sales Channels"
},
{
"id": "dirit-partenaire-transverse",
"name": "DirIT Partenaire Transverse",
"direction": "DIR IT Partenaires"
},
{
"id": "seller",
"name": "SELLER",
"direction": "DIR IT Partenaires"
},
{
"id": "generix-a-nettoyer",
"name": "GENERIX a nettoyer",
@@ -500,16 +375,6 @@
"name": "TEAM QUALITE VENDEUR",
"direction": "DIR IT EXPERIENCE CLIENTS"
},
{
"id": "cdiscount-advertising",
"name": "Cdiscount Advertising",
"direction": "DIR IT Partenaires"
},
{
"id": "flux-partenaires",
"name": "Flux Partenaires",
"direction": "DIR IT Partenaires"
},
{
"id": "return",
"name": "RETURN",

View File

@@ -0,0 +1,146 @@
export interface Skill {
id: string;
name: string;
description: string;
icon: string;
categoryId: string;
category: string;
usageCount: number;
}
export interface Team {
id: string;
name: string;
direction: string;
memberCount: number;
}
export class AdminManagementService {
private static baseUrl = "/api/admin";
// Skills Management
static async getSkills(): Promise<Skill[]> {
const response = await fetch(`${this.baseUrl}/skills`);
if (!response.ok) {
throw new Error("Failed to fetch skills");
}
return response.json();
}
static async createSkill(
skillData: Omit<Skill, "id" | "usageCount">
): Promise<Skill> {
const response = await fetch(`${this.baseUrl}/skills`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(skillData),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "Failed to create skill");
}
return response.json();
}
static async updateSkill(skillData: Skill): Promise<Skill> {
const response = await fetch(`${this.baseUrl}/skills`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(skillData),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "Failed to update skill");
}
return response.json();
}
static async deleteSkill(skillId: string): Promise<void> {
const response = await fetch(`${this.baseUrl}/skills?id=${skillId}`, {
method: "DELETE",
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "Failed to delete skill");
}
}
// Teams Management
static async getTeams(): Promise<Team[]> {
const response = await fetch(`${this.baseUrl}/teams`);
if (!response.ok) {
throw new Error("Failed to fetch teams");
}
return response.json();
}
static async createTeam(
teamData: Omit<Team, "id" | "memberCount">
): Promise<Team> {
const response = await fetch(`${this.baseUrl}/teams`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(teamData),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "Failed to create team");
}
return response.json();
}
static async updateTeam(teamData: Team): Promise<Team> {
const response = await fetch(`${this.baseUrl}/teams`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(teamData),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "Failed to update team");
}
return response.json();
}
static async deleteTeam(teamId: string): Promise<void> {
const response = await fetch(`${this.baseUrl}/teams?id=${teamId}`, {
method: "DELETE",
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "Failed to delete team");
}
}
static async deleteDirection(direction: string): Promise<void> {
const response = await fetch(
`${this.baseUrl}/teams?direction=${encodeURIComponent(direction)}`,
{
method: "DELETE",
}
);
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || "Failed to delete direction");
}
}
}

View File

@@ -17,5 +17,8 @@ export { SkillsService } from "./skills-service";
// Admin services (server-only)
export { AdminService } from "./admin-service";
// Admin management services (client-side compatible)
export { AdminManagementService } from "./admin-management-service";
// API client (can be used client-side)
export { ApiClient, apiClient } from "./api-client";