Compare commits

..

6 Commits

Author SHA1 Message Date
bb16aaee40 chore: update pnpm-lock.yaml to include swr and associated dependencies
Added `swr@2.3.3` with peer and regular dependencies (`react@19.1.0`, `dequal@2.0.3`, `use-sync-external-store@1.5.0`). Updated lock file to reflect changes.
2025-05-16 14:45:06 +02:00
bb62a374c5 docs: update project status to reflect revised progress and priorities
Revised `PROJECT_STATUS.md` to adjust module completion statuses, testing progress, and timeline estimates. Refocused priorities on missing modules, authentication enhancements, and test/documentation improvements.
2025-05-16 14:44:57 +02:00
a56e774892 test: remove redundant JwtAuthGuard mock in unit test file 2025-05-16 14:44:28 +02:00
cd5ad2e1e4 feat: implement API service, middleware, and authentication context
- Added `lib/api.ts` to centralize API communication for authentication, projects, persons, tags, and groups.
- Introduced `middleware.ts` to handle route protection based on authentication and roles.
- Created `auth-context.tsx` to manage authentication state with `AuthProvider` and `useAuth` hook.
- Updated `package.json` to include `swr` for data fetching.
- Enhanced project documentation (`RESPONSIVE_DESIGN.md` and `README.md`) with responsive design and architecture details.
2025-05-16 14:43:56 +02:00
cab80e6aef feat: add dashboard, projects, and persons pages with reusable components
Implemented the following:
- `DashboardPage`: displays an overview of stats, recent projects, and tabs for future analytics/reports.
- `ProjectsPage` and `PersonsPage`: include searchable tables, actions, and mobile-friendly card views.
- Integrated reusable components like `AuthLoading`, `DropdownMenu`, `Table`, and `Card`.
2025-05-16 14:43:14 +02:00
753669c622 feat: add reusable frontend components for admin, dashboard, and tag management
Implemented reusable components:
- `TagSelector`: a customizable tag selection control with asynchronous mock data loading.
- `AuthLoading`: a loading state wrapper for authentication processes.
- `AdminLayout` and `DashboardLayout`: layouts with navigation and user management features.
- `ThemeProvider`: supports dynamic theme toggling.
2025-05-16 14:42:58 +02:00
45 changed files with 7127 additions and 170 deletions

View File

@ -3,19 +3,6 @@ import { Reflector } from '@nestjs/core';
import { JwtAuthGuard } from './jwt-auth.guard';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
// Mock the AuthGuard
jest.mock('@nestjs/passport', () => {
return {
AuthGuard: jest.fn().mockImplementation(() => {
return class {
canActivate() {
return true;
}
};
}),
};
});
describe('JwtAuthGuard', () => {
let guard: JwtAuthGuard;
let reflector: Reflector;

View File

@ -22,21 +22,19 @@ Nous avons élaboré un plan de bataille complet pour l'implémentation du backe
#### Composants En Cours
- ⏳ Relations entre les modules existants
- ⏳ Tests unitaires et e2e
#### Composants Récemment Implémentés
- ✅ Système de migrations de base de données avec DrizzleORM
- ✅ Tests unitaires pour les modules auth, groups et tags
#### Composants Non Implémentés
- Module d'authentification avec GitHub OAuth
- Stratégies JWT pour la gestion des sessions
- Module d'authentification avec GitHub OAuth
- Stratégies JWT pour la gestion des sessions
- ✅ Guards et décorateurs pour la protection des routes
- Module groupes
- Module tags
- Module groupes
- Module tags
- ❌ Communication en temps réel avec Socket.IO
- ❌ Fonctionnalités de conformité RGPD
- ❌ Tests e2e complets
- ⏳ Tests unitaires et e2e
- ❌ Documentation API avec Swagger
### Frontend
@ -74,17 +72,9 @@ Nous avons élaboré un plan de bataille complet pour l'implémentation du backe
- [x] Implémenter le refresh token
##### Modules Manquants
- [x] Implémenter le module groupes (contrôleurs, services, DTOs)
- [x] Implémenter le module tags (contrôleurs, services, DTOs)
- [x] Compléter les relations entre les modules existants
##### Tests Unitaires
- [x] Écrire des tests unitaires pour le module auth
- [x] Écrire des tests unitaires pour le module groups
- [x] Écrire des tests unitaires pour le module tags
- [x] Écrire des tests unitaires pour le module persons
- [x] Écrire des tests unitaires pour le module projects
- [x] Écrire des tests unitaires pour le module users
- [ ] Implémenter le module groupes (contrôleurs, services, DTOs)
- [ ] Implémenter le module tags (contrôleurs, services, DTOs)
- [ ] Compléter les relations entre les modules existants
#### Priorité Moyenne
@ -105,9 +95,8 @@ Nous avons élaboré un plan de bataille complet pour l'implémentation du backe
#### Priorité Basse
##### Tests et Documentation
- [x] Écrire des tests unitaires pour les services (tous les modules)
- [x] Écrire des tests unitaires pour les contrôleurs (tous les modules)
- [x] Écrire des tests unitaires pour tous les modules
- [ ] Écrire des tests unitaires pour les services
- [ ] Écrire des tests unitaires pour les contrôleurs
- [ ] Développer des tests e2e pour les API
- [ ] Configurer Swagger pour la documentation API
- [ ] Documenter les endpoints API
@ -180,10 +169,10 @@ Nous avons élaboré un plan de bataille complet pour l'implémentation du backe
- Configurer les stratégies JWT pour la gestion des sessions ✅
- Créer les guards et décorateurs pour la protection des routes ✅
2. **Modules et Relations**
- Implémenter le module groupes
- Implémenter le module tags
- Compléter les relations entre les modules existants
2. **Modules Manquants**
- Implémenter le module groupes
- Implémenter le module tags
- Compléter les relations entre les modules existants
### Frontend (Priorité Haute)
1. **Authentification**
@ -202,10 +191,10 @@ Nous avons élaboré un plan de bataille complet pour l'implémentation du backe
|-----------|-------------|
| Backend - Structure de Base | 90% |
| Backend - Base de Données | 100% |
| Backend - Modules Fonctionnels | 80% |
| Backend - Modules Fonctionnels | 60% |
| Backend - Authentification | 90% |
| Backend - WebSockets | 0% |
| Backend - Tests et Documentation | 70% |
| Backend - Tests et Documentation | 20% |
| Frontend - Structure de Base | 70% |
| Frontend - Pages et Composants | 10% |
| Frontend - Authentification | 0% |
@ -216,12 +205,11 @@ Nous avons élaboré un plan de bataille complet pour l'implémentation du backe
Basé sur l'état d'avancement actuel et les tâches restantes, l'estimation du temps nécessaire pour compléter le projet est la suivante:
- **Backend**: ~2-3 semaines
- **Backend**: ~3-4 semaines
- Authentification: ✅ Terminé
- Modules manquants: ✅ Terminé
- Relations entre modules: 1 semaine
- Modules manquants: 1-2 semaines
- WebSockets: 1 semaine
- Tests e2e et documentation: 1 semaine
- Tests et documentation: 1 semaine
- **Frontend**: ~5-6 semaines
- Authentification: 1 semaine
@ -247,6 +235,4 @@ Basé sur l'état d'avancement actuel et les tâches restantes, l'estimation du
## Conclusion
Le projet a considérablement progressé avec l'implémentation complète de la structure de base, du schéma de données, de l'authentification, et des modules principaux (utilisateurs, projets, personnes, groupes, tags). Les tests unitaires pour les services et contrôleurs ont également été mis en place.
Les prochaines étapes prioritaires devraient se concentrer sur la complétion des relations entre les modules existants et le développement des fonctionnalités de communication en temps réel avec Socket.IO. Côté frontend, il est maintenant temps de commencer l'implémentation des pages d'authentification et des fonctionnalités de base.
Le projet a bien avancé sur la structure de base et la définition du schéma de données, mais il reste encore un travail significatif à réaliser. Les prochaines étapes prioritaires devraient se concentrer sur l'authentification et les fonctionnalités de base pour avoir rapidement une version minimale fonctionnelle.

View File

@ -1,36 +1,95 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
# Frontend Implementation
## Getting Started
This document provides an overview of the frontend implementation for the "Application de Création de Groupes" project.
First, run the development server:
## Architecture
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
The frontend is built with Next.js 15 using the App Router architecture. It follows a component-based approach with a clear separation of concerns:
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
- **app/**: Contains all the pages and layouts organized by route
- **components/**: Reusable UI components
- **lib/**: Utility functions, hooks, and services
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
## Authentication Flow
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
The application uses GitHub OAuth for authentication:
## Learn More
1. User clicks "Login with GitHub" on the login page
2. User is redirected to GitHub for authorization
3. GitHub redirects back to our callback page with an authorization code
4. The callback page exchanges the code for an access token
5. User information is stored in the AuthContext and localStorage
6. User is redirected to the dashboard or the original page they were trying to access
To learn more about Next.js, take a look at the following resources:
### Authentication Components
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
- **AuthProvider**: Context provider that manages authentication state
- **AuthLoading**: Component that displays a loading screen during authentication checks
- **useAuth**: Hook to access authentication state and methods
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## API Communication
## Deploy on Vercel
All API communication is centralized in the `lib/api.ts` file, which provides:
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
- A base `fetchAPI` function with error handling and authentication
- Specific API modules for different resources (auth, projects, persons, tags, groups)
- Type-safe methods for all API operations
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
## Protected Routes
All authenticated routes are protected by:
1. **Middleware**: Redirects unauthenticated users to the login page
2. **AuthLoading**: Shows a loading screen during authentication checks
3. **AuthContext**: Provides user information and authentication methods
## Layout Structure
The application uses a nested layout structure:
- **RootLayout**: Provides global styles and the AuthProvider
- **DashboardLayout**: Provides the sidebar navigation and user interface for authenticated pages
- **AdminLayout**: Provides the admin interface for admin-only pages
## Components
### UI Components
The application uses ShadcnUI for UI components, which provides:
- A consistent design system
- Accessible components
- Dark mode support
### Custom Components
- **dashboard-layout.tsx**: Main layout for authenticated pages
- **auth-loading.tsx**: Loading component for authentication checks
- **admin-layout.tsx**: Layout for admin pages
## Future Development
### Adding New Pages
1. Create a new directory in the `app/` folder
2. Create a `page.tsx` file with your page content
3. Create a `layout.tsx` file that uses the appropriate layout and AuthLoading component
### Adding New API Endpoints
1. Add new methods to the appropriate API module in `lib/api.ts`
2. Use the methods in your components with the `useEffect` hook or event handlers
### Adding New Features
1. Create new components in the `components/` folder
2. Use the components in your pages
3. Add new API methods if needed
## Best Practices
- Use the AuthContext for authentication-related operations
- Use the API service for all API communication
- Wrap authenticated pages with the AuthLoading component
- Use TypeScript for type safety
- Follow the component-based architecture

View File

@ -0,0 +1,10 @@
import { AdminLayout } from "@/components/admin-layout";
import { AuthLoading } from "@/components/auth-loading";
export default function AdminRootLayout({ children }: { children: React.ReactNode }) {
return (
<AuthLoading>
<AdminLayout>{children}</AdminLayout>
</AuthLoading>
);
}

199
frontend/app/admin/page.tsx Normal file
View File

@ -0,0 +1,199 @@
"use client";
import { useState } from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { Users, Shield, Tags, Settings, BarChart4 } from "lucide-react";
import Link from "next/link";
export default function AdminDashboardPage() {
const [activeTab, setActiveTab] = useState("overview");
// Mock data for the admin dashboard
const stats = [
{
title: "Utilisateurs",
value: "24",
description: "Utilisateurs actifs",
icon: Users,
href: "/admin/users",
},
{
title: "Tags globaux",
value: "18",
description: "Tags disponibles",
icon: Tags,
href: "/admin/tags",
},
{
title: "Projets",
value: "32",
description: "Projets créés",
icon: BarChart4,
href: "/admin/stats",
},
{
title: "Paramètres",
value: "7",
description: "Paramètres système",
icon: Settings,
href: "/admin/settings",
},
];
// Mock data for recent activities
const recentActivities = [
{
id: 1,
user: "Jean Dupont",
action: "a créé un nouveau projet",
target: "Formation Dev Web",
date: "2025-05-15T14:32:00",
},
{
id: 2,
user: "Marie Martin",
action: "a modifié un tag global",
target: "Frontend",
date: "2025-05-15T13:45:00",
},
{
id: 3,
user: "Admin",
action: "a ajouté un nouvel utilisateur",
target: "Pierre Durand",
date: "2025-05-15T11:20:00",
},
{
id: 4,
user: "Sophie Lefebvre",
action: "a créé un nouveau groupe",
target: "Groupe A",
date: "2025-05-15T10:15:00",
},
{
id: 5,
user: "Admin",
action: "a modifié les paramètres système",
target: "Paramètres de notification",
date: "2025-05-14T16:30:00",
},
];
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<h1 className="text-2xl sm:text-3xl font-bold">Administration</h1>
<div className="flex items-center gap-2">
<Shield className="h-5 w-5 text-primary" />
<span className="text-sm text-muted-foreground">Mode administrateur</span>
</div>
</div>
<Tabs defaultValue="overview" className="space-y-4" onValueChange={setActiveTab}>
<TabsList className="w-full flex justify-start overflow-auto">
<TabsTrigger value="overview" className="flex-1 sm:flex-none">Vue d'ensemble</TabsTrigger>
<TabsTrigger value="activity" className="flex-1 sm:flex-none">Activité récente</TabsTrigger>
<TabsTrigger value="system" className="flex-1 sm:flex-none">Système</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-4">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{stats.map((stat, index) => (
<Card key={index}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{stat.title}</CardTitle>
<stat.icon className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stat.value}</div>
<p className="text-xs text-muted-foreground">{stat.description}</p>
<Button variant="link" asChild className="px-0 mt-2">
<Link href={stat.href}>Gérer</Link>
</Button>
</CardContent>
</Card>
))}
</div>
</TabsContent>
<TabsContent value="activity" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Activité récente</CardTitle>
<CardDescription>
Les dernières actions effectuées sur la plateforme
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{recentActivities.map((activity) => (
<div key={activity.id} className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-2 border-b pb-4 last:border-0 last:pb-0">
<div className="space-y-1">
<p className="font-medium">
<span className="font-semibold">{activity.user}</span> {activity.action}{" "}
<span className="font-semibold">{activity.target}</span>
</p>
<p className="text-sm text-muted-foreground">
{new Date(activity.date).toLocaleString("fr-FR", {
dateStyle: "medium",
timeStyle: "short",
})}
</p>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="system" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Informations système</CardTitle>
<CardDescription>
Informations sur l'état du système
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<p className="text-sm font-medium">Version de l'application</p>
<p className="text-sm text-muted-foreground">v1.0.0</p>
</div>
<div className="space-y-2">
<p className="text-sm font-medium">Dernière mise à jour</p>
<p className="text-sm text-muted-foreground">15 mai 2025</p>
</div>
<div className="space-y-2">
<p className="text-sm font-medium">État du serveur</p>
<div className="flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-green-500"></div>
<p className="text-sm text-muted-foreground">En ligne</p>
</div>
</div>
<div className="space-y-2">
<p className="text-sm font-medium">Utilisation de la base de données</p>
<p className="text-sm text-muted-foreground">42%</p>
</div>
</div>
<div className="pt-4">
<Button asChild>
<Link href="/admin/settings">
<Settings className="mr-2 h-4 w-4" />
Paramètres système
</Link>
</Button>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}

View File

@ -0,0 +1,581 @@
"use client";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { toast } from "sonner";
import {
Save,
RefreshCw,
Shield,
Bell,
Mail,
Database,
Server,
FileJson,
Loader2
} from "lucide-react";
export default function AdminSettingsPage() {
const [activeTab, setActiveTab] = useState("general");
const [isLoading, setIsLoading] = useState(false);
// Mock system settings
const systemSettings = {
general: {
siteName: "Application de Création de Groupes",
siteDescription: "Une application web moderne dédiée à la création et à la gestion de groupes",
contactEmail: "admin@example.com",
maxProjectsPerUser: "10",
maxPersonsPerProject: "100",
},
authentication: {
enableGithubAuth: true,
requireEmailVerification: false,
sessionTimeout: "7",
maxLoginAttempts: "5",
passwordMinLength: "8",
},
notifications: {
enableEmailNotifications: true,
enableSystemNotifications: true,
notifyOnNewUser: true,
notifyOnNewProject: false,
adminEmailRecipients: "admin@example.com",
},
maintenance: {
maintenanceMode: false,
maintenanceMessage: "Le site est actuellement en maintenance. Veuillez réessayer plus tard.",
debugMode: false,
logLevel: "error",
},
};
const { register: registerGeneral, handleSubmit: handleSubmitGeneral, formState: { errors: errorsGeneral } } = useForm({
defaultValues: systemSettings.general,
});
const { register: registerAuth, handleSubmit: handleSubmitAuth, formState: { errors: errorsAuth } } = useForm({
defaultValues: systemSettings.authentication,
});
const { register: registerNotif, handleSubmit: handleSubmitNotif, formState: { errors: errorsNotif } } = useForm({
defaultValues: systemSettings.notifications,
});
const { register: registerMaint, handleSubmit: handleSubmitMaint, formState: { errors: errorsMaint } } = useForm({
defaultValues: systemSettings.maintenance,
});
const onSubmitGeneral = async (data: any) => {
setIsLoading(true);
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 1000));
setIsLoading(false);
toast.success("Paramètres généraux mis à jour avec succès");
};
const onSubmitAuth = async (data: any) => {
setIsLoading(true);
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 1000));
setIsLoading(false);
toast.success("Paramètres d'authentification mis à jour avec succès");
};
const onSubmitNotif = async (data: any) => {
setIsLoading(true);
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 1000));
setIsLoading(false);
toast.success("Paramètres de notification mis à jour avec succès");
};
const onSubmitMaint = async (data: any) => {
setIsLoading(true);
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 1000));
setIsLoading(false);
toast.success("Paramètres de maintenance mis à jour avec succès");
};
const handleExportConfig = async () => {
setIsLoading(true);
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 1000));
setIsLoading(false);
toast.success("Configuration exportée avec succès");
};
const handleClearCache = async () => {
setIsLoading(true);
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 1000));
setIsLoading(false);
toast.success("Cache vidé avec succès");
};
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<h1 className="text-2xl sm:text-3xl font-bold">Paramètres système</h1>
<div className="flex items-center gap-2">
<Shield className="h-5 w-5 text-primary" />
<span className="text-sm text-muted-foreground">Configuration globale</span>
</div>
</div>
<Tabs defaultValue="general" className="space-y-4" onValueChange={setActiveTab}>
<TabsList className="w-full flex justify-start overflow-auto">
<TabsTrigger value="general" className="flex-1 sm:flex-none">Général</TabsTrigger>
<TabsTrigger value="authentication" className="flex-1 sm:flex-none">Authentification</TabsTrigger>
<TabsTrigger value="notifications" className="flex-1 sm:flex-none">Notifications</TabsTrigger>
<TabsTrigger value="maintenance" className="flex-1 sm:flex-none">Maintenance</TabsTrigger>
</TabsList>
<TabsContent value="general" className="space-y-4">
<Card>
<form onSubmit={handleSubmitGeneral(onSubmitGeneral)}>
<CardHeader>
<CardTitle>Paramètres généraux</CardTitle>
<CardDescription>
Configurez les paramètres généraux de l'application
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="siteName">Nom du site</Label>
<Input
id="siteName"
{...registerGeneral("siteName", { required: "Le nom du site est requis" })}
/>
{errorsGeneral.siteName && (
<p className="text-sm text-destructive">{errorsGeneral.siteName.message as string}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="contactEmail">Email de contact</Label>
<Input
id="contactEmail"
type="email"
{...registerGeneral("contactEmail", {
required: "L'email de contact est requis",
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
message: "Adresse email invalide"
}
})}
/>
{errorsGeneral.contactEmail && (
<p className="text-sm text-destructive">{errorsGeneral.contactEmail.message as string}</p>
)}
</div>
</div>
<div className="space-y-2">
<Label htmlFor="siteDescription">Description du site</Label>
<Textarea
id="siteDescription"
rows={3}
{...registerGeneral("siteDescription")}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="maxProjectsPerUser">Nombre max. de projets par utilisateur</Label>
<Input
id="maxProjectsPerUser"
type="number"
{...registerGeneral("maxProjectsPerUser", {
required: "Ce champ est requis",
min: { value: 1, message: "La valeur minimale est 1" }
})}
/>
{errorsGeneral.maxProjectsPerUser && (
<p className="text-sm text-destructive">{errorsGeneral.maxProjectsPerUser.message as string}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="maxPersonsPerProject">Nombre max. de personnes par projet</Label>
<Input
id="maxPersonsPerProject"
type="number"
{...registerGeneral("maxPersonsPerProject", {
required: "Ce champ est requis",
min: { value: 1, message: "La valeur minimale est 1" }
})}
/>
{errorsGeneral.maxPersonsPerProject && (
<p className="text-sm text-destructive">{errorsGeneral.maxPersonsPerProject.message as string}</p>
)}
</div>
</div>
</CardContent>
<CardFooter>
<Button type="submit" disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Enregistrement...
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
Enregistrer les modifications
</>
)}
</Button>
</CardFooter>
</form>
</Card>
</TabsContent>
<TabsContent value="authentication" className="space-y-4">
<Card>
<form onSubmit={handleSubmitAuth(onSubmitAuth)}>
<CardHeader>
<CardTitle>Paramètres d'authentification</CardTitle>
<CardDescription>
Configurez les options d'authentification et de sécurité
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="enableGithubAuth">Authentification GitHub</Label>
<p className="text-sm text-muted-foreground">
Activer l'authentification via GitHub OAuth
</p>
</div>
<Switch
id="enableGithubAuth"
{...registerAuth("enableGithubAuth")}
defaultChecked={systemSettings.authentication.enableGithubAuth}
/>
</div>
<Separator />
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="requireEmailVerification">Vérification d'email</Label>
<p className="text-sm text-muted-foreground">
Exiger la vérification de l'email lors de l'inscription
</p>
</div>
<Switch
id="requireEmailVerification"
{...registerAuth("requireEmailVerification")}
defaultChecked={systemSettings.authentication.requireEmailVerification}
/>
</div>
<Separator />
<div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-2">
<Label htmlFor="sessionTimeout">Durée de session (jours)</Label>
<Input
id="sessionTimeout"
type="number"
{...registerAuth("sessionTimeout", {
required: "Ce champ est requis",
min: { value: 1, message: "La valeur minimale est 1" }
})}
/>
{errorsAuth.sessionTimeout && (
<p className="text-sm text-destructive">{errorsAuth.sessionTimeout.message as string}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="maxLoginAttempts">Tentatives de connexion max.</Label>
<Input
id="maxLoginAttempts"
type="number"
{...registerAuth("maxLoginAttempts", {
required: "Ce champ est requis",
min: { value: 1, message: "La valeur minimale est 1" }
})}
/>
{errorsAuth.maxLoginAttempts && (
<p className="text-sm text-destructive">{errorsAuth.maxLoginAttempts.message as string}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="passwordMinLength">Longueur min. du mot de passe</Label>
<Input
id="passwordMinLength"
type="number"
{...registerAuth("passwordMinLength", {
required: "Ce champ est requis",
min: { value: 6, message: "La valeur minimale est 6" }
})}
/>
{errorsAuth.passwordMinLength && (
<p className="text-sm text-destructive">{errorsAuth.passwordMinLength.message as string}</p>
)}
</div>
</div>
</div>
</CardContent>
<CardFooter>
<Button type="submit" disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Enregistrement...
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
Enregistrer les modifications
</>
)}
</Button>
</CardFooter>
</form>
</Card>
</TabsContent>
<TabsContent value="notifications" className="space-y-4">
<Card>
<form onSubmit={handleSubmitNotif(onSubmitNotif)}>
<CardHeader>
<CardTitle>Paramètres de notification</CardTitle>
<CardDescription>
Configurez les options de notification système et email
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="enableEmailNotifications">Notifications par email</Label>
<p className="text-sm text-muted-foreground">
Activer l'envoi de notifications par email
</p>
</div>
<Switch
id="enableEmailNotifications"
{...registerNotif("enableEmailNotifications")}
defaultChecked={systemSettings.notifications.enableEmailNotifications}
/>
</div>
<Separator />
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="enableSystemNotifications">Notifications système</Label>
<p className="text-sm text-muted-foreground">
Activer les notifications dans l'application
</p>
</div>
<Switch
id="enableSystemNotifications"
{...registerNotif("enableSystemNotifications")}
defaultChecked={systemSettings.notifications.enableSystemNotifications}
/>
</div>
<Separator />
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="notifyOnNewUser">Notification nouvel utilisateur</Label>
<p className="text-sm text-muted-foreground">
Notifier les administrateurs lors de l'inscription d'un nouvel utilisateur
</p>
</div>
<Switch
id="notifyOnNewUser"
{...registerNotif("notifyOnNewUser")}
defaultChecked={systemSettings.notifications.notifyOnNewUser}
/>
</div>
<Separator />
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="notifyOnNewProject">Notification nouveau projet</Label>
<p className="text-sm text-muted-foreground">
Notifier les administrateurs lors de la création d'un nouveau projet
</p>
</div>
<Switch
id="notifyOnNewProject"
{...registerNotif("notifyOnNewProject")}
defaultChecked={systemSettings.notifications.notifyOnNewProject}
/>
</div>
<Separator />
<div className="space-y-2">
<Label htmlFor="adminEmailRecipients">Destinataires des emails administratifs</Label>
<Input
id="adminEmailRecipients"
{...registerNotif("adminEmailRecipients", {
required: "Ce champ est requis",
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
message: "Adresse email invalide"
}
})}
/>
<p className="text-xs text-muted-foreground">
Séparez les adresses email par des virgules pour plusieurs destinataires
</p>
{errorsNotif.adminEmailRecipients && (
<p className="text-sm text-destructive">{errorsNotif.adminEmailRecipients.message as string}</p>
)}
</div>
</div>
</CardContent>
<CardFooter>
<Button type="submit" disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Enregistrement...
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
Enregistrer les modifications
</>
)}
</Button>
</CardFooter>
</form>
</Card>
</TabsContent>
<TabsContent value="maintenance" className="space-y-4">
<Card>
<form onSubmit={handleSubmitMaint(onSubmitMaint)}>
<CardHeader>
<CardTitle>Maintenance et débogage</CardTitle>
<CardDescription>
Configurez les options de maintenance et de débogage
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="maintenanceMode" className="font-semibold text-destructive">Mode maintenance</Label>
<p className="text-sm text-muted-foreground">
Activer le mode maintenance (le site sera inaccessible aux utilisateurs)
</p>
</div>
<Switch
id="maintenanceMode"
{...registerMaint("maintenanceMode")}
defaultChecked={systemSettings.maintenance.maintenanceMode}
/>
</div>
<div className="space-y-2">
<Label htmlFor="maintenanceMessage">Message de maintenance</Label>
<Textarea
id="maintenanceMessage"
rows={3}
{...registerMaint("maintenanceMessage")}
/>
</div>
<Separator />
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="debugMode">Mode débogage</Label>
<p className="text-sm text-muted-foreground">
Activer le mode débogage (affiche des informations supplémentaires)
</p>
</div>
<Switch
id="debugMode"
{...registerMaint("debugMode")}
defaultChecked={systemSettings.maintenance.debugMode}
/>
</div>
<div className="space-y-2">
<Label htmlFor="logLevel">Niveau de journalisation</Label>
<select
id="logLevel"
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50"
{...registerMaint("logLevel")}
>
<option value="error">Error</option>
<option value="warn">Warning</option>
<option value="info">Info</option>
<option value="debug">Debug</option>
<option value="trace">Trace</option>
</select>
</div>
<Separator />
<div className="grid gap-4 sm:grid-cols-2">
<div>
<Button
type="button"
variant="outline"
className="w-full"
onClick={handleExportConfig}
disabled={isLoading}
>
<FileJson className="mr-2 h-4 w-4" />
Exporter la configuration
</Button>
</div>
<div>
<Button
type="button"
variant="outline"
className="w-full"
onClick={handleClearCache}
disabled={isLoading}
>
<RefreshCw className="mr-2 h-4 w-4" />
Vider le cache
</Button>
</div>
</div>
</div>
</CardContent>
<CardFooter>
<Button type="submit" disabled={isLoading}>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Enregistrement...
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
Enregistrer les modifications
</>
)}
</Button>
</CardFooter>
</form>
</Card>
</TabsContent>
</Tabs>
</div>
);
}

View File

@ -0,0 +1,319 @@
"use client";
import { useState } from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
PieChart,
Pie,
Cell,
LineChart,
Line
} from "recharts";
import {
BarChart4,
Users,
FolderKanban,
Tags,
Calendar,
Download
} from "lucide-react";
import { Button } from "@/components/ui/button";
// Mock data for charts
const userRegistrationData = [
{ name: "Jan", count: 4 },
{ name: "Fév", count: 3 },
{ name: "Mar", count: 5 },
{ name: "Avr", count: 7 },
{ name: "Mai", count: 2 },
{ name: "Juin", count: 6 },
{ name: "Juil", count: 8 },
{ name: "Août", count: 9 },
{ name: "Sep", count: 11 },
{ name: "Oct", count: 13 },
{ name: "Nov", count: 7 },
{ name: "Déc", count: 5 },
];
const projectCreationData = [
{ name: "Jan", count: 2 },
{ name: "Fév", count: 4 },
{ name: "Mar", count: 3 },
{ name: "Avr", count: 5 },
{ name: "Mai", count: 1 },
{ name: "Juin", count: 3 },
{ name: "Juil", count: 6 },
{ name: "Août", count: 4 },
{ name: "Sep", count: 7 },
{ name: "Oct", count: 8 },
{ name: "Nov", count: 5 },
{ name: "Déc", count: 3 },
];
const userRoleData = [
{ name: "Administrateurs", value: 3 },
{ name: "Utilisateurs standard", value: 21 },
];
const tagUsageData = [
{ name: "Frontend", value: 12 },
{ name: "Backend", value: 8 },
{ name: "Fullstack", value: 5 },
{ name: "UX/UI", value: 3 },
{ name: "DevOps", value: 2 },
];
const COLORS = ['#0088FE', '#00C49F', '#FFBB28', '#FF8042', '#8884d8'];
const dailyActiveUsersData = [
{ name: "Lun", users: 15 },
{ name: "Mar", users: 18 },
{ name: "Mer", users: 22 },
{ name: "Jeu", users: 19 },
{ name: "Ven", users: 23 },
{ name: "Sam", users: 12 },
{ name: "Dim", users: 10 },
];
export default function AdminStatsPage() {
const [activeTab, setActiveTab] = useState("overview");
// Mock statistics
const stats = [
{
title: "Utilisateurs",
value: "24",
change: "+12%",
trend: "up",
icon: Users,
},
{
title: "Projets",
value: "32",
change: "+8%",
trend: "up",
icon: FolderKanban,
},
{
title: "Groupes créés",
value: "128",
change: "+15%",
trend: "up",
icon: Users,
},
{
title: "Tags utilisés",
value: "18",
change: "+5%",
trend: "up",
icon: Tags,
},
];
const handleExportStats = () => {
alert("Statistiques exportées en CSV");
};
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<h1 className="text-2xl sm:text-3xl font-bold">Statistiques</h1>
<Button onClick={handleExportStats} className="w-full sm:w-auto">
<Download className="mr-2 h-4 w-4" />
Exporter en CSV
</Button>
</div>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{stats.map((stat, index) => (
<Card key={index}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{stat.title}</CardTitle>
<stat.icon className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stat.value}</div>
<p className={`text-xs ${stat.trend === 'up' ? 'text-green-500' : 'text-red-500'}`}>
{stat.change} depuis le mois dernier
</p>
</CardContent>
</Card>
))}
</div>
<Tabs defaultValue="users" className="space-y-4" onValueChange={setActiveTab}>
<TabsList className="w-full flex justify-start overflow-auto">
<TabsTrigger value="users" className="flex-1 sm:flex-none">Utilisateurs</TabsTrigger>
<TabsTrigger value="projects" className="flex-1 sm:flex-none">Projets</TabsTrigger>
<TabsTrigger value="tags" className="flex-1 sm:flex-none">Tags</TabsTrigger>
<TabsTrigger value="activity" className="flex-1 sm:flex-none">Activité</TabsTrigger>
</TabsList>
<TabsContent value="users" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Inscriptions d'utilisateurs par mois</CardTitle>
<CardDescription>
Nombre de nouveaux utilisateurs inscrits par mois
</CardDescription>
</CardHeader>
<CardContent className="h-[300px] sm:h-[400px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={userRegistrationData}
margin={{
top: 5,
right: 30,
left: 20,
bottom: 5,
}}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" />
<YAxis />
<Tooltip />
<Legend />
<Bar dataKey="count" fill="#8884d8" name="Utilisateurs" />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Répartition des rôles utilisateurs</CardTitle>
<CardDescription>
Proportion d'administrateurs et d'utilisateurs standard
</CardDescription>
</CardHeader>
<CardContent className="h-[300px]">
<ResponsiveContainer width="100%" height="100%">
<PieChart>
<Pie
data={userRoleData}
cx="50%"
cy="50%"
labelLine={true}
label={({ name, percent }) => `${name}: ${(percent * 100).toFixed(0)}%`}
outerRadius={80}
fill="#8884d8"
dataKey="value"
>
{userRoleData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Tooltip />
<Legend />
</PieChart>
</ResponsiveContainer>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="projects" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Création de projets par mois</CardTitle>
<CardDescription>
Nombre de nouveaux projets créés par mois
</CardDescription>
</CardHeader>
<CardContent className="h-[300px] sm:h-[400px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart
data={projectCreationData}
margin={{
top: 5,
right: 30,
left: 20,
bottom: 5,
}}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" />
<YAxis />
<Tooltip />
<Legend />
<Bar dataKey="count" fill="#00C49F" name="Projets" />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="tags" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Utilisation des tags</CardTitle>
<CardDescription>
Nombre d'utilisations par tag
</CardDescription>
</CardHeader>
<CardContent className="h-[300px] sm:h-[400px]">
<ResponsiveContainer width="100%" height="100%">
<BarChart
layout="vertical"
data={tagUsageData}
margin={{
top: 5,
right: 30,
left: 60,
bottom: 5,
}}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis type="number" />
<YAxis dataKey="name" type="category" />
<Tooltip />
<Legend />
<Bar dataKey="value" fill="#FFBB28" name="Utilisations" />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="activity" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Utilisateurs actifs par jour</CardTitle>
<CardDescription>
Nombre d'utilisateurs actifs par jour de la semaine
</CardDescription>
</CardHeader>
<CardContent className="h-[300px] sm:h-[400px]">
<ResponsiveContainer width="100%" height="100%">
<LineChart
data={dailyActiveUsersData}
margin={{
top: 5,
right: 30,
left: 20,
bottom: 5,
}}
>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="name" />
<YAxis />
<Tooltip />
<Legend />
<Line type="monotone" dataKey="users" stroke="#FF8042" name="Utilisateurs actifs" />
</LineChart>
</ResponsiveContainer>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}

View File

@ -0,0 +1,278 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from "@/components/ui/table";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from "@/components/ui/dropdown-menu";
import {
PlusCircle,
Search,
MoreHorizontal,
Pencil,
Trash2,
Users,
CircleDot
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { toast } from "sonner";
export default function AdminTagsPage() {
const [searchQuery, setSearchQuery] = useState("");
// Mock data for global tags
const tags = [
{
id: 1,
name: "Frontend",
description: "Développement frontend",
color: "blue",
usageCount: 12,
global: true,
createdBy: "Admin",
},
{
id: 2,
name: "Backend",
description: "Développement backend",
color: "green",
usageCount: 8,
global: true,
createdBy: "Admin",
},
{
id: 3,
name: "Fullstack",
description: "Développement fullstack",
color: "purple",
usageCount: 5,
global: true,
createdBy: "Admin",
},
{
id: 4,
name: "UX/UI",
description: "Design UX/UI",
color: "pink",
usageCount: 3,
global: true,
createdBy: "Marie Martin",
},
{
id: 5,
name: "DevOps",
description: "Infrastructure et déploiement",
color: "orange",
usageCount: 2,
global: true,
createdBy: "Thomas Bernard",
},
{
id: 6,
name: "Junior",
description: "Niveau junior",
color: "yellow",
usageCount: 7,
global: true,
createdBy: "Admin",
},
{
id: 7,
name: "Medior",
description: "Niveau intermédiaire",
color: "amber",
usageCount: 5,
global: true,
createdBy: "Admin",
},
{
id: 8,
name: "Senior",
description: "Niveau senior",
color: "red",
usageCount: 6,
global: true,
createdBy: "Admin",
},
];
// Map color names to Tailwind classes
const colorMap: Record<string, string> = {
blue: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300",
green: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300",
purple: "bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300",
pink: "bg-pink-100 text-pink-800 dark:bg-pink-900 dark:text-pink-300",
orange: "bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300",
yellow: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300",
amber: "bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-300",
red: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300",
};
// Filter tags based on search query
const filteredTags = tags.filter(
(tag) =>
tag.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
tag.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
tag.createdBy.toLowerCase().includes(searchQuery.toLowerCase())
);
const handleDeleteTag = (tagId: number) => {
toast.success(`Tag #${tagId} supprimé avec succès`);
};
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<h1 className="text-2xl sm:text-3xl font-bold">Tags globaux</h1>
<Button asChild className="w-full sm:w-auto">
<Link href="/admin/tags/new">
<PlusCircle className="mr-2 h-4 w-4" />
Nouveau tag global
</Link>
</Button>
</div>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
type="search"
placeholder="Rechercher des tags..."
className="pl-8"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
</div>
{/* Mobile card view */}
<div className="grid gap-4 sm:hidden">
{filteredTags.length === 0 ? (
<div className="rounded-md border p-6 text-center text-muted-foreground">
Aucun tag trouvé.
</div>
) : (
filteredTags.map((tag) => (
<div key={tag.id} className="rounded-md border p-4">
<div className="flex items-center justify-between">
<Badge className={colorMap[tag.color]}>
{tag.name}
</Badge>
<Badge variant="outline" className="text-xs">
{tag.usageCount} utilisations
</Badge>
</div>
<div className="mt-2">
<p className="text-sm text-muted-foreground">{tag.description}</p>
</div>
<div className="mt-2 text-xs text-muted-foreground">
Créé par: {tag.createdBy}
</div>
<div className="mt-4 flex justify-end gap-2">
<Button variant="outline" size="sm" asChild>
<Link href={`/admin/tags/${tag.id}/edit`}>
<Pencil className="mr-2 h-4 w-4" />
Modifier
</Link>
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteTag(tag.id)}
className="text-destructive hover:text-destructive"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))
)}
</div>
{/* Desktop table view */}
<div className="rounded-md border hidden sm:block overflow-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Tag</TableHead>
<TableHead>Description</TableHead>
<TableHead>Utilisations</TableHead>
<TableHead>Créé par</TableHead>
<TableHead className="w-[100px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredTags.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="h-24 text-center">
Aucun tag trouvé.
</TableCell>
</TableRow>
) : (
filteredTags.map((tag) => (
<TableRow key={tag.id}>
<TableCell>
<Badge className={colorMap[tag.color]}>
{tag.name}
</Badge>
</TableCell>
<TableCell>{tag.description}</TableCell>
<TableCell>{tag.usageCount}</TableCell>
<TableCell>{tag.createdBy}</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">Actions</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href={`/admin/tags/${tag.id}/edit`} className="flex items-center">
<Pencil className="mr-2 h-4 w-4" />
<span>Modifier</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/admin/tags/${tag.id}/usage`} className="flex items-center">
<Users className="mr-2 h-4 w-4" />
<span>Voir les utilisations</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDeleteTag(tag.id)}
className="text-destructive focus:text-destructive flex items-center"
>
<Trash2 className="mr-2 h-4 w-4" />
<span>Supprimer</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
);
}

View File

@ -0,0 +1,301 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from "@/components/ui/table";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from "@/components/ui/dropdown-menu";
import {
PlusCircle,
Search,
MoreHorizontal,
Pencil,
Trash2,
Shield,
UserCog
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { toast } from "sonner";
export default function AdminUsersPage() {
const [searchQuery, setSearchQuery] = useState("");
// Mock data for users
const users = [
{
id: 1,
name: "Jean Dupont",
email: "jean.dupont@example.com",
role: "user",
status: "active",
lastLogin: "2025-05-15T14:32:00",
projects: 3,
},
{
id: 2,
name: "Marie Martin",
email: "marie.martin@example.com",
role: "admin",
status: "active",
lastLogin: "2025-05-15T13:45:00",
projects: 5,
},
{
id: 3,
name: "Pierre Durand",
email: "pierre.durand@example.com",
role: "user",
status: "inactive",
lastLogin: "2025-05-10T11:20:00",
projects: 1,
},
{
id: 4,
name: "Sophie Lefebvre",
email: "sophie.lefebvre@example.com",
role: "user",
status: "active",
lastLogin: "2025-05-15T10:15:00",
projects: 2,
},
{
id: 5,
name: "Thomas Bernard",
email: "thomas.bernard@example.com",
role: "admin",
status: "active",
lastLogin: "2025-05-14T16:30:00",
projects: 0,
},
];
// Filter users based on search query
const filteredUsers = users.filter(
(user) =>
user.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
user.email.toLowerCase().includes(searchQuery.toLowerCase()) ||
user.role.toLowerCase().includes(searchQuery.toLowerCase())
);
const handleDeleteUser = (userId: number) => {
toast.success(`Utilisateur #${userId} supprimé avec succès`);
};
const handleChangeRole = (userId: number, newRole: string) => {
toast.success(`Rôle de l'utilisateur #${userId} changé en ${newRole}`);
};
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<h1 className="text-2xl sm:text-3xl font-bold">Gestion des utilisateurs</h1>
<Button asChild className="w-full sm:w-auto">
<Link href="/admin/users/new">
<PlusCircle className="mr-2 h-4 w-4" />
Nouvel utilisateur
</Link>
</Button>
</div>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
type="search"
placeholder="Rechercher des utilisateurs..."
className="pl-8"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
</div>
{/* Mobile card view */}
<div className="grid gap-4 sm:hidden">
{filteredUsers.length === 0 ? (
<div className="rounded-md border p-6 text-center text-muted-foreground">
Aucun utilisateur trouvé.
</div>
) : (
filteredUsers.map((user) => (
<div key={user.id} className="rounded-md border p-4">
<div className="flex items-center justify-between">
<div className="flex flex-col">
<span className="font-medium">{user.name}</span>
<span className="text-sm text-muted-foreground">{user.email}</span>
</div>
<Badge variant={user.role === "admin" ? "default" : "outline"}>
{user.role === "admin" ? (
<Shield className="mr-1 h-3 w-3" />
) : null}
{user.role === "admin" ? "Admin" : "Utilisateur"}
</Badge>
</div>
<div className="mt-2 grid grid-cols-2 gap-2 text-sm">
<div>
<span className="text-muted-foreground">Statut: </span>
<Badge variant={user.status === "active" ? "secondary" : "destructive"} className="ml-1">
{user.status === "active" ? "Actif" : "Inactif"}
</Badge>
</div>
<div>
<span className="text-muted-foreground">Projets: </span>
<span>{user.projects}</span>
</div>
<div className="col-span-2">
<span className="text-muted-foreground">Dernière connexion: </span>
<span>
{new Date(user.lastLogin).toLocaleString("fr-FR", {
dateStyle: "medium",
timeStyle: "short",
})}
</span>
</div>
</div>
<div className="mt-4 flex justify-end gap-2">
<Button variant="outline" size="sm" asChild>
<Link href={`/admin/users/${user.id}`}>
<UserCog className="mr-2 h-4 w-4" />
Gérer
</Link>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href={`/admin/users/${user.id}/edit`}>
<Pencil className="mr-2 h-4 w-4" />
Modifier
</Link>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleChangeRole(user.id, user.role === "admin" ? "user" : "admin")}
>
<Shield className="mr-2 h-4 w-4" />
{user.role === "admin" ? "Retirer les droits admin" : "Promouvoir admin"}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDeleteUser(user.id)}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
Supprimer
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
))
)}
</div>
{/* Desktop table view */}
<div className="rounded-md border hidden sm:block overflow-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Nom</TableHead>
<TableHead>Email</TableHead>
<TableHead>Rôle</TableHead>
<TableHead>Statut</TableHead>
<TableHead>Dernière connexion</TableHead>
<TableHead>Projets</TableHead>
<TableHead className="w-[100px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredUsers.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="h-24 text-center">
Aucun utilisateur trouvé.
</TableCell>
</TableRow>
) : (
filteredUsers.map((user) => (
<TableRow key={user.id}>
<TableCell className="font-medium">{user.name}</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell>
<Badge variant={user.role === "admin" ? "default" : "outline"}>
{user.role === "admin" ? (
<Shield className="mr-1 h-3 w-3" />
) : null}
{user.role === "admin" ? "Admin" : "Utilisateur"}
</Badge>
</TableCell>
<TableCell>
<Badge variant={user.status === "active" ? "secondary" : "destructive"}>
{user.status === "active" ? "Actif" : "Inactif"}
</Badge>
</TableCell>
<TableCell>
{new Date(user.lastLogin).toLocaleString("fr-FR", {
dateStyle: "medium",
timeStyle: "short",
})}
</TableCell>
<TableCell>{user.projects}</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">Actions</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href={`/admin/users/${user.id}/edit`} className="flex items-center">
<Pencil className="mr-2 h-4 w-4" />
<span>Modifier</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleChangeRole(user.id, user.role === "admin" ? "user" : "admin")}
className="flex items-center"
>
<Shield className="mr-2 h-4 w-4" />
<span>{user.role === "admin" ? "Retirer les droits admin" : "Promouvoir admin"}</span>
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleDeleteUser(user.id)}
className="text-destructive focus:text-destructive flex items-center"
>
<Trash2 className="mr-2 h-4 w-4" />
<span>Supprimer</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
);
}

View File

@ -0,0 +1,79 @@
"use client";
import { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { Loader2 } from "lucide-react";
import { useAuth } from "@/lib/auth-context";
export default function CallbackPage() {
const router = useRouter();
const [error, setError] = useState<string | null>(null);
const { login, user } = useAuth();
useEffect(() => {
async function handleCallback() {
try {
// Get the code from the URL query parameters
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');
if (!code) {
throw new Error('No authorization code found in the URL');
}
// Use the auth context to login
await login(code);
// Check if there's a stored callbackUrl
const callbackUrl = sessionStorage.getItem('callbackUrl');
// Clear the stored callbackUrl
sessionStorage.removeItem('callbackUrl');
// Redirect based on role and callbackUrl
if (callbackUrl) {
// For admin routes, check if user has admin role
if (callbackUrl.startsWith('/admin') && user?.role !== 'ADMIN') {
router.push('/dashboard');
} else {
router.push(callbackUrl);
}
} else {
// Default redirects if no callbackUrl
if (user && user.role === 'ADMIN') {
router.push('/admin');
} else {
router.push('/dashboard');
}
}
} catch (err) {
console.error("Authentication error:", err);
setError("Une erreur est survenue lors de l'authentification. Veuillez réessayer.");
}
}
handleCallback();
}, [router]);
if (error) {
return (
<div className="flex min-h-screen flex-col items-center justify-center p-4 text-center">
<div className="mb-4 text-red-500">{error}</div>
<a
href="/auth/login"
className="text-primary hover:underline"
>
Retour à la page de connexion
</a>
</div>
);
}
return (
<div className="flex min-h-screen flex-col items-center justify-center p-4 text-center">
<Loader2 className="mb-4 h-8 w-8 animate-spin text-primary" />
<h1 className="mb-2 text-xl font-semibold">Authentification en cours...</h1>
<p className="text-muted-foreground">Vous allez être redirigé vers l'application.</p>
</div>
);
}

View File

@ -0,0 +1,74 @@
"use client";
import { useState } from "react";
import { Github } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
export default function LoginPage() {
const [isLoading, setIsLoading] = useState(false);
const handleGitHubLogin = async () => {
setIsLoading(true);
try {
// Get the callbackUrl from the URL if present
const urlParams = new URLSearchParams(window.location.search);
const callbackUrl = urlParams.get('callbackUrl');
// Use the API service to get the GitHub OAuth URL
const { url } = await import('@/lib/api').then(module =>
module.authAPI.getGitHubOAuthUrl()
);
// Store the callbackUrl in sessionStorage to use after authentication
if (callbackUrl) {
sessionStorage.setItem('callbackUrl', callbackUrl);
}
// Redirect to GitHub OAuth page
window.location.href = url;
} catch (error) {
console.error('Login error:', error);
setIsLoading(false);
// You could add error handling UI here
}
};
return (
<div className="flex min-h-screen items-center justify-center bg-background p-4">
<Card className="w-full max-w-md">
<CardHeader className="space-y-1 text-center">
<CardTitle className="text-2xl font-bold">Connexion</CardTitle>
<CardDescription>
Connectez-vous pour accéder à l&apos;application de création de groupes
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-4">
<Button
variant="outline"
onClick={handleGitHubLogin}
disabled={isLoading}
className="w-full"
>
{isLoading ? (
<span className="flex items-center gap-2">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-current border-t-transparent" />
Connexion en cours...
</span>
) : (
<span className="flex items-center gap-2">
<Github className="h-5 w-5" />
Se connecter avec GitHub
</span>
)}
</Button>
</CardContent>
<CardFooter className="flex flex-col text-center text-sm text-muted-foreground">
<p>
En vous connectant, vous acceptez nos conditions d&apos;utilisation et notre politique de confidentialité.
</p>
</CardFooter>
</Card>
</div>
);
}

View File

@ -0,0 +1,36 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
import { Loader2 } from "lucide-react";
import { useAuth } from "@/lib/auth-context";
export default function LogoutPage() {
const router = useRouter();
const { logout } = useAuth();
useEffect(() => {
async function handleLogout() {
try {
// Use the auth context to logout
await logout();
// Note: The auth context handles clearing localStorage and redirecting
} catch (error) {
console.error('Logout error:', error);
// Even if there's an error, still redirect to login
router.push('/auth/login');
}
}
handleLogout();
}, [router]);
return (
<div className="flex min-h-screen flex-col items-center justify-center p-4 text-center">
<Loader2 className="mb-4 h-8 w-8 animate-spin text-primary" />
<h1 className="mb-2 text-xl font-semibold">Déconnexion en cours...</h1>
<p className="text-muted-foreground">Vous allez être redirigé vers la page de connexion.</p>
</div>
);
}

View File

@ -0,0 +1,10 @@
import { DashboardLayout } from "@/components/dashboard-layout";
import { AuthLoading } from "@/components/auth-loading";
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<AuthLoading>
<DashboardLayout>{children}</DashboardLayout>
</AuthLoading>
);
}

View File

@ -0,0 +1,176 @@
"use client";
import { useState } from "react";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { PlusCircle, Users, FolderKanban, Tags } from "lucide-react";
import Link from "next/link";
export default function DashboardPage() {
const [activeTab, setActiveTab] = useState("overview");
// Mock data for the dashboard
const stats = [
{
title: "Projets",
value: "5",
description: "Projets actifs",
icon: FolderKanban,
href: "/projects",
},
{
title: "Personnes",
value: "42",
description: "Personnes enregistrées",
icon: Users,
href: "/persons",
},
{
title: "Tags",
value: "12",
description: "Tags disponibles",
icon: Tags,
href: "/tags",
},
];
// Mock data for recent projects
const recentProjects = [
{
id: 1,
name: "Projet Formation Dev Web",
description: "Création de groupes pour la formation développement web",
date: "2025-05-15",
groups: 4,
persons: 16,
},
{
id: 2,
name: "Projet Hackathon",
description: "Équipes pour le hackathon annuel",
date: "2025-05-10",
groups: 8,
persons: 32,
},
{
id: 3,
name: "Projet Workshop UX/UI",
description: "Groupes pour l'atelier UX/UI",
date: "2025-05-05",
groups: 5,
persons: 20,
},
];
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<h1 className="text-2xl sm:text-3xl font-bold">Tableau de bord</h1>
<Button asChild className="w-full sm:w-auto">
<Link href="/projects/new">
<PlusCircle className="mr-2 h-4 w-4" />
Nouveau projet
</Link>
</Button>
</div>
<Tabs defaultValue="overview" className="space-y-4" onValueChange={setActiveTab}>
<TabsList className="w-full flex justify-start overflow-auto">
<TabsTrigger value="overview" className="flex-1 sm:flex-none">Vue d'ensemble</TabsTrigger>
<TabsTrigger value="analytics" className="flex-1 sm:flex-none">Analytiques</TabsTrigger>
<TabsTrigger value="reports" className="flex-1 sm:flex-none">Rapports</TabsTrigger>
</TabsList>
<TabsContent value="overview" className="space-y-4">
<div className="grid gap-4 md:grid-cols-3">
{stats.map((stat, index) => (
<Card key={index}>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium">{stat.title}</CardTitle>
<div className="flex items-center justify-center">
<stat.icon className="h-4 w-4 text-muted-foreground" />
</div>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{stat.value}</div>
<p className="text-xs text-muted-foreground">{stat.description}</p>
<Button variant="link" asChild className="px-0 mt-2">
<Link href={stat.href}>Voir tous</Link>
</Button>
</CardContent>
</Card>
))}
</div>
<div className="grid gap-4">
<Card>
<CardHeader>
<CardTitle>Projets récents</CardTitle>
<CardDescription>
Vous avez {recentProjects.length} projets récents
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-4">
{recentProjects.map((project) => (
<div key={project.id} className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 border-b pb-4 last:border-0 last:pb-0">
<div className="flex flex-col gap-2 min-w-0">
<div className="flex flex-col gap-1">
<p className="font-medium truncate">{project.name}</p>
<p className="text-sm text-muted-foreground line-clamp-2">{project.description}</p>
</div>
<div className="flex flex-wrap items-center gap-2 sm:gap-4 text-xs text-muted-foreground">
<span>{new Date(project.date).toLocaleDateString("fr-FR")}</span>
<span>{project.groups} groupes</span>
<span>{project.persons} personnes</span>
</div>
</div>
<div className="flex sm:flex-shrink-0">
<Button variant="outline" asChild className="w-full sm:w-auto">
<Link href={`/projects/${project.id}`}>Voir</Link>
</Button>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
</TabsContent>
<TabsContent value="analytics" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Analytiques</CardTitle>
<CardDescription>
Visualisez les statistiques de vos projets et groupes
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex h-[300px] items-center justify-center rounded-md border border-dashed">
<p className="text-sm text-muted-foreground">
Les graphiques d'analytiques seront disponibles prochainement
</p>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="reports" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Rapports</CardTitle>
<CardDescription>
Générez des rapports sur vos projets et groupes
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex h-[300px] items-center justify-center rounded-md border border-dashed">
<p className="text-sm text-muted-foreground">
La génération de rapports sera disponible prochainement
</p>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}

View File

@ -1,6 +1,8 @@
import type { Metadata } from "next";
import type { Metadata, Viewport } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { ThemeProvider } from "@/components/theme-provider";
import { AuthProvider } from "@/lib/auth-context";
const geistSans = Geist({
variable: "--font-geist-sans",
@ -13,8 +15,15 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "Application de Création de Groupes",
description: "Une application web moderne dédiée à la création et à la gestion de groupes",
};
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
maximumScale: 1,
userScalable: true,
};
export default function RootLayout({
@ -23,11 +32,20 @@ export default function RootLayout({
children: React.ReactNode;
}>) {
return (
<html lang="en">
<html lang="fr" suppressHydrationWarning>
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
<AuthProvider>
<ThemeProvider
attribute="class"
defaultTheme="system"
enableSystem
disableTransitionOnChange
>
{children}
</ThemeProvider>
</AuthProvider>
</body>
</html>
);

View File

@ -1,102 +1,113 @@
import Image from "next/image";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import {
Users,
FolderKanban,
Tags,
ArrowRight
} from "lucide-react";
export default function Home() {
return (
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]">
<main className="flex flex-col gap-[32px] row-start-2 items-center sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={180}
height={38}
priority
/>
<ol className="list-inside list-decimal text-sm/6 text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
<li className="mb-2 tracking-[-.01em]">
Get started by editing{" "}
<code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-[family-name:var(--font-geist-mono)] font-semibold">
app/page.tsx
</code>
.
</li>
<li className="tracking-[-.01em]">
Save and see your changes instantly.
</li>
</ol>
<div className="flex gap-4 items-center flex-col sm:flex-row">
<a
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:w-auto"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={20}
height={20}
/>
Deploy now
</a>
<a
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent font-medium text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 w-full sm:w-auto md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Read our docs
</a>
<div className="flex min-h-screen flex-col">
{/* Header */}
<header className="border-b">
<div className="container flex h-16 items-center justify-between px-4 md:px-6">
<div className="flex items-center gap-2">
<span className="text-xl font-bold">Groupes</span>
</div>
<nav className="hidden gap-6 md:flex">
<Link href="/auth/login" className="text-sm font-medium hover:underline">
Connexion
</Link>
</nav>
<div className="flex md:hidden">
<Button asChild>
<Link href="/auth/login">Connexion</Link>
</Button>
</div>
</div>
</header>
{/* Hero Section */}
<section className="w-full py-12 md:py-24 lg:py-32">
<div className="container px-4 md:px-6">
<div className="flex flex-col items-center justify-center gap-6 text-center">
<div className="flex flex-col gap-4">
<h1 className="text-3xl font-bold tracking-tighter sm:text-4xl md:text-5xl">
Application de Création de Groupes
</h1>
<p className="mx-auto max-w-[700px] text-muted-foreground md:text-xl">
Une application web moderne dédiée à la création et à la gestion de groupes, permettant aux utilisateurs de créer des groupes selon différents critères.
</p>
</div>
<div className="flex w-full justify-center">
<Button asChild size="lg" className="w-full max-w-sm sm:w-auto">
<Link href="/auth/login" className="flex items-center justify-center">
Commencer <ArrowRight className="ml-2 h-4 w-4" />
</Link>
</Button>
</div>
</div>
</div>
</section>
{/* Features Section */}
<section className="w-full bg-muted py-12 md:py-24 lg:py-32">
<div className="container px-4 md:px-6">
<div className="mx-auto grid max-w-5xl items-center gap-8 py-12 sm:grid-cols-2 lg:grid-cols-3">
<div className="flex flex-col justify-center gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary text-primary-foreground">
<FolderKanban className="h-6 w-6" />
</div>
<div className="flex flex-col gap-2">
<h3 className="text-xl font-bold">Gestion de Projets</h3>
<p className="text-muted-foreground">
Créez et gérez des projets de groupe avec une liste de personnes et des critères personnalisés.
</p>
</div>
</div>
<div className="flex flex-col justify-center gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary text-primary-foreground">
<Users className="h-6 w-6" />
</div>
<div className="flex flex-col gap-2">
<h3 className="text-xl font-bold">Création de Groupes</h3>
<p className="text-muted-foreground">
Utilisez notre assistant pour créer automatiquement des groupes équilibrés ou créez-les manuellement.
</p>
</div>
</div>
<div className="flex flex-col justify-center gap-4">
<div className="flex h-12 w-12 items-center justify-center rounded-lg bg-primary text-primary-foreground">
<Tags className="h-6 w-6" />
</div>
<div className="flex flex-col gap-2">
<h3 className="text-xl font-bold">Gestion des Tags</h3>
<p className="text-muted-foreground">
Attribuez des tags aux personnes pour faciliter la création de groupes équilibrés.
</p>
</div>
</div>
</div>
</div>
</section>
{/* Footer */}
<footer className="border-t py-6 md:py-8">
<div className="container flex flex-col sm:flex-row items-center justify-center sm:justify-between gap-4 px-4 md:px-6">
<p className="text-center sm:text-left text-sm text-muted-foreground">
© 2025 Application de Création de Groupes. Tous droits réservés.
</p>
<div className="flex items-center gap-4">
<Link href="/terms" className="text-sm text-muted-foreground hover:underline">
Conditions d'utilisation
</Link>
<Link href="/privacy" className="text-sm text-muted-foreground hover:underline">
Confidentialité
</Link>
</div>
</div>
</main>
<footer className="row-start-3 flex gap-[24px] flex-wrap items-center justify-center">
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/file.svg"
alt="File icon"
width={16}
height={16}
/>
Learn
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/window.svg"
alt="Window icon"
width={16}
height={16}
/>
Examples
</a>
<a
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
href="https://nextjs.org?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
aria-hidden
src="/globe.svg"
alt="Globe icon"
width={16}
height={16}
/>
Go to nextjs.org
</a>
</footer>
</div>
);

View File

@ -0,0 +1,346 @@
"use client";
import { useState, useEffect } from "react";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import { useForm, Controller } from "react-hook-form";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
ArrowLeft,
Loader2,
Save,
Plus,
X
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { toast } from "sonner";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
// Type definitions
interface PersonFormData {
name: string;
email: string;
level: string;
}
// Mock data for available tags
const availableTags = [
"Frontend", "Backend", "Fullstack", "UX/UI", "DevOps",
"React", "Vue", "Angular", "Node.js", "Python", "Java", "PHP",
"JavaScript", "TypeScript", "CSS", "Docker", "Kubernetes", "Design",
"Figma", "MERN"
];
// Levels
const levels = ["Junior", "Medior", "Senior"];
// Mock person data
const getPersonData = (id: string) => {
return {
id: parseInt(id),
name: "Jean Dupont",
email: "jean.dupont@example.com",
tags: ["Frontend", "React", "Junior"],
};
};
export default function EditPersonPage() {
const params = useParams();
const router = useRouter();
const personId = params.id as string;
const [person, setPerson] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [tagInput, setTagInput] = useState("");
const [filteredTags, setFilteredTags] = useState<string[]>([]);
const { register, handleSubmit, control, formState: { errors }, reset } = useForm<PersonFormData>();
// Filter available tags based on input
useEffect(() => {
if (tagInput) {
const filtered = availableTags.filter(
tag =>
tag.toLowerCase().includes(tagInput.toLowerCase()) &&
!selectedTags.includes(tag)
);
setFilteredTags(filtered);
} else {
setFilteredTags([]);
}
}, [tagInput, selectedTags]);
useEffect(() => {
// Simulate API call to fetch person data
const fetchPerson = async () => {
setLoading(true);
try {
// In a real app, this would be an API call
await new Promise(resolve => setTimeout(resolve, 1000));
const data = getPersonData(personId);
setPerson(data);
// Extract level from tags (assuming the last tag is the level)
const level = data.tags.find(tag => ["Junior", "Medior", "Senior"].includes(tag)) || "";
// Set selected tags (excluding the level)
const tags = data.tags.filter(tag => !["Junior", "Medior", "Senior"].includes(tag));
setSelectedTags(tags);
// Reset form with person data
reset({
name: data.name,
email: data.email,
level: level
});
} catch (error) {
console.error("Error fetching person:", error);
toast.error("Erreur lors du chargement de la personne");
} finally {
setLoading(false);
}
};
fetchPerson();
}, [personId, reset]);
const handleAddTag = (tag: string) => {
if (!selectedTags.includes(tag)) {
setSelectedTags([...selectedTags, tag]);
}
setTagInput("");
setFilteredTags([]);
};
const handleRemoveTag = (tag: string) => {
setSelectedTags(selectedTags.filter(t => t !== tag));
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && tagInput) {
e.preventDefault();
if (filteredTags.length > 0) {
handleAddTag(filteredTags[0]);
} else if (!selectedTags.includes(tagInput)) {
// Add as a new tag if it doesn't exist
handleAddTag(tagInput);
}
}
};
const onSubmit = async (data: PersonFormData) => {
if (selectedTags.length === 0) {
toast.error("Veuillez sélectionner au moins un tag");
return;
}
setIsSubmitting(true);
try {
// In a real app, this would be an API call to update the person
await new Promise(resolve => setTimeout(resolve, 1000));
// Combine form data with selected tags
const personData = {
...data,
tags: [...selectedTags, data.level]
};
toast.success("Personne mise à jour avec succès");
router.push("/persons");
} catch (error) {
console.error("Error updating person:", error);
toast.error("Erreur lors de la mise à jour de la personne");
} finally {
setIsSubmitting(false);
}
};
if (loading) {
return (
<div className="flex h-[50vh] items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
);
}
if (!person) {
return (
<div className="flex h-[50vh] flex-col items-center justify-center">
<p className="text-lg font-medium">Personne non trouvée</p>
<Button asChild className="mt-4">
<Link href="/persons">Retour aux personnes</Link>
</Button>
</div>
);
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-2">
<Button variant="outline" size="icon" asChild>
<Link href="/persons">
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
<h1 className="text-3xl font-bold">Modifier la personne</h1>
</div>
<Card>
<form onSubmit={handleSubmit(onSubmit)}>
<CardHeader>
<CardTitle>Informations de la personne</CardTitle>
<CardDescription>
Modifiez les informations et les tags de la personne
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Nom</Label>
<Input
id="name"
placeholder="Ex: Jean Dupont"
{...register("name", {
required: "Le nom est requis",
minLength: {
value: 3,
message: "Le nom doit contenir au moins 3 caractères"
}
})}
/>
{errors.name && (
<p className="text-sm text-destructive">{errors.name.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="Ex: jean.dupont@example.com"
{...register("email", {
required: "L'email est requis",
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
message: "Adresse email invalide"
}
})}
/>
{errors.email && (
<p className="text-sm text-destructive">{errors.email.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="level">Niveau</Label>
<Controller
name="level"
control={control}
rules={{ required: "Le niveau est requis" }}
render={({ field }) => (
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<SelectTrigger>
<SelectValue placeholder="Sélectionnez un niveau" />
</SelectTrigger>
<SelectContent>
{levels.map((level) => (
<SelectItem key={level} value={level}>
{level}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
{errors.level && (
<p className="text-sm text-destructive">{errors.level.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="tags">Tags</Label>
<div className="flex flex-wrap gap-1 mb-2">
{selectedTags.map((tag) => (
<Badge key={tag} variant="secondary" className="flex items-center gap-1">
{tag}
<Button
type="button"
variant="ghost"
size="icon"
className="h-4 w-4 p-0 text-muted-foreground hover:text-foreground"
onClick={() => handleRemoveTag(tag)}
>
<X className="h-3 w-3" />
<span className="sr-only">Supprimer le tag {tag}</span>
</Button>
</Badge>
))}
</div>
<div className="relative">
<Input
id="tags"
placeholder="Rechercher ou ajouter un tag..."
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={handleKeyDown}
/>
{filteredTags.length > 0 && (
<div className="absolute z-10 mt-1 w-full rounded-md border bg-popover p-2 shadow-md">
<div className="max-h-60 overflow-auto">
{filteredTags.map((tag) => (
<div
key={tag}
className="flex cursor-pointer items-center rounded-md px-2 py-1.5 hover:bg-muted"
onClick={() => handleAddTag(tag)}
>
<Plus className="mr-2 h-4 w-4" />
{tag}
</div>
))}
</div>
</div>
)}
</div>
<p className="text-xs text-muted-foreground">
Appuyez sur Entrée pour ajouter un tag ou sélectionnez-en un dans la liste
</p>
</div>
</CardContent>
<CardFooter className="flex justify-between">
<Button variant="outline" asChild>
<Link href="/persons">Annuler</Link>
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Enregistrement...
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
Enregistrer les modifications
</>
)}
</Button>
</CardFooter>
</form>
</Card>
</div>
);
}

View File

@ -0,0 +1,10 @@
import { DashboardLayout } from "@/components/dashboard-layout";
import { AuthLoading } from "@/components/auth-loading";
export default function PersonsLayout({ children }: { children: React.ReactNode }) {
return (
<AuthLoading>
<DashboardLayout>{children}</DashboardLayout>
</AuthLoading>
);
}

View File

@ -0,0 +1,286 @@
"use client";
import { useState, useEffect } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { useForm, Controller } from "react-hook-form";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
ArrowLeft,
Loader2,
Save,
Plus,
X
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { toast } from "sonner";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
// Type definitions
interface PersonFormData {
name: string;
email: string;
level: string;
}
// Mock data for available tags
const availableTags = [
"Frontend", "Backend", "Fullstack", "UX/UI", "DevOps",
"React", "Vue", "Angular", "Node.js", "Python", "Java", "PHP",
"JavaScript", "TypeScript", "CSS", "Docker", "Kubernetes", "Design",
"Figma", "MERN"
];
// Levels
const levels = ["Junior", "Medior", "Senior"];
export default function NewPersonPage() {
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [tagInput, setTagInput] = useState("");
const [filteredTags, setFilteredTags] = useState<string[]>([]);
const { register, handleSubmit, control, formState: { errors } } = useForm<PersonFormData>({
defaultValues: {
name: "",
email: "",
level: ""
}
});
// Filter available tags based on input
useEffect(() => {
if (tagInput) {
const filtered = availableTags.filter(
tag =>
tag.toLowerCase().includes(tagInput.toLowerCase()) &&
!selectedTags.includes(tag)
);
setFilteredTags(filtered);
} else {
setFilteredTags([]);
}
}, [tagInput, selectedTags]);
const handleAddTag = (tag: string) => {
if (!selectedTags.includes(tag)) {
setSelectedTags([...selectedTags, tag]);
}
setTagInput("");
setFilteredTags([]);
};
const handleRemoveTag = (tag: string) => {
setSelectedTags(selectedTags.filter(t => t !== tag));
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter' && tagInput) {
e.preventDefault();
if (filteredTags.length > 0) {
handleAddTag(filteredTags[0]);
} else if (!selectedTags.includes(tagInput)) {
// Add as a new tag if it doesn't exist
handleAddTag(tagInput);
}
}
};
const onSubmit = async (data: PersonFormData) => {
if (selectedTags.length === 0) {
toast.error("Veuillez sélectionner au moins un tag");
return;
}
setIsSubmitting(true);
try {
// In a real app, this would be an API call to create a new person
await new Promise(resolve => setTimeout(resolve, 1000));
// Combine form data with selected tags
const personData = {
...data,
tags: [...selectedTags, data.level]
};
// Simulate a successful response with a person ID
const personId = Date.now();
toast.success("Personne créée avec succès");
router.push("/persons");
} catch (error) {
console.error("Error creating person:", error);
toast.error("Erreur lors de la création de la personne");
} finally {
setIsSubmitting(false);
}
};
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-2">
<Button variant="outline" size="icon" asChild>
<Link href="/persons">
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
<h1 className="text-3xl font-bold">Nouvelle personne</h1>
</div>
<Card>
<form onSubmit={handleSubmit(onSubmit)}>
<CardHeader>
<CardTitle>Informations de la personne</CardTitle>
<CardDescription>
Ajoutez une nouvelle personne à votre projet
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Nom</Label>
<Input
id="name"
placeholder="Ex: Jean Dupont"
{...register("name", {
required: "Le nom est requis",
minLength: {
value: 3,
message: "Le nom doit contenir au moins 3 caractères"
}
})}
/>
{errors.name && (
<p className="text-sm text-destructive">{errors.name.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="Ex: jean.dupont@example.com"
{...register("email", {
required: "L'email est requis",
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
message: "Adresse email invalide"
}
})}
/>
{errors.email && (
<p className="text-sm text-destructive">{errors.email.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="level">Niveau</Label>
<Controller
name="level"
control={control}
rules={{ required: "Le niveau est requis" }}
render={({ field }) => (
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<SelectTrigger>
<SelectValue placeholder="Sélectionnez un niveau" />
</SelectTrigger>
<SelectContent>
{levels.map((level) => (
<SelectItem key={level} value={level}>
{level}
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
{errors.level && (
<p className="text-sm text-destructive">{errors.level.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="tags">Tags</Label>
<div className="flex flex-wrap gap-1 mb-2">
{selectedTags.map((tag) => (
<Badge key={tag} variant="secondary" className="flex items-center gap-1">
{tag}
<Button
type="button"
variant="ghost"
size="icon"
className="h-4 w-4 p-0 text-muted-foreground hover:text-foreground"
onClick={() => handleRemoveTag(tag)}
>
<X className="h-3 w-3" />
<span className="sr-only">Supprimer le tag {tag}</span>
</Button>
</Badge>
))}
</div>
<div className="relative">
<Input
id="tags"
placeholder="Rechercher ou ajouter un tag..."
value={tagInput}
onChange={(e) => setTagInput(e.target.value)}
onKeyDown={handleKeyDown}
/>
{filteredTags.length > 0 && (
<div className="absolute z-10 mt-1 w-full rounded-md border bg-popover p-2 shadow-md">
<div className="max-h-60 overflow-auto">
{filteredTags.map((tag) => (
<div
key={tag}
className="flex cursor-pointer items-center rounded-md px-2 py-1.5 hover:bg-muted"
onClick={() => handleAddTag(tag)}
>
<Plus className="mr-2 h-4 w-4" />
{tag}
</div>
))}
</div>
</div>
)}
</div>
<p className="text-xs text-muted-foreground">
Appuyez sur Entrée pour ajouter un tag ou sélectionnez-en un dans la liste
</p>
</div>
</CardContent>
<CardFooter className="flex justify-between">
<Button variant="outline" asChild>
<Link href="/persons">Annuler</Link>
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Création en cours...
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
Créer la personne
</>
)}
</Button>
</CardFooter>
</form>
</Card>
</div>
);
}

View File

@ -0,0 +1,193 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from "@/components/ui/table";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from "@/components/ui/dropdown-menu";
import {
PlusCircle,
Search,
MoreHorizontal,
Pencil,
Trash2,
Tag
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
export default function PersonsPage() {
const [searchQuery, setSearchQuery] = useState("");
// Mock data for persons
const persons = [
{
id: 1,
name: "Jean Dupont",
email: "jean.dupont@example.com",
tags: ["Frontend", "React", "Junior"],
projects: 2,
},
{
id: 2,
name: "Marie Martin",
email: "marie.martin@example.com",
tags: ["Backend", "Node.js", "Senior"],
projects: 3,
},
{
id: 3,
name: "Pierre Durand",
email: "pierre.durand@example.com",
tags: ["Fullstack", "JavaScript", "Medior"],
projects: 1,
},
{
id: 4,
name: "Sophie Lefebvre",
email: "sophie.lefebvre@example.com",
tags: ["UX/UI", "Design", "Senior"],
projects: 2,
},
{
id: 5,
name: "Thomas Bernard",
email: "thomas.bernard@example.com",
tags: ["Backend", "Java", "Senior"],
projects: 1,
},
{
id: 6,
name: "Julie Petit",
email: "julie.petit@example.com",
tags: ["Frontend", "Vue", "Junior"],
projects: 2,
},
{
id: 7,
name: "Nicolas Moreau",
email: "nicolas.moreau@example.com",
tags: ["DevOps", "Docker", "Medior"],
projects: 3,
},
];
// Filter persons based on search query
const filteredPersons = persons.filter(
(person) =>
person.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
person.email.toLowerCase().includes(searchQuery.toLowerCase()) ||
person.tags.some((tag) => tag.toLowerCase().includes(searchQuery.toLowerCase()))
);
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<h1 className="text-3xl font-bold">Personnes</h1>
<Button asChild>
<Link href="/persons/new">
<PlusCircle className="mr-2 h-4 w-4" />
Nouvelle personne
</Link>
</Button>
</div>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
type="search"
placeholder="Rechercher des personnes..."
className="pl-8"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Nom</TableHead>
<TableHead>Email</TableHead>
<TableHead>Tags</TableHead>
<TableHead>Projets</TableHead>
<TableHead className="w-[100px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredPersons.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="h-24 text-center">
Aucune personne trouvée.
</TableCell>
</TableRow>
) : (
filteredPersons.map((person) => (
<TableRow key={person.id}>
<TableCell className="font-medium">{person.name}</TableCell>
<TableCell>{person.email}</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{person.tags.map((tag, index) => (
<Badge key={index} variant="outline">
{tag}
</Badge>
))}
</div>
</TableCell>
<TableCell>{person.projects}</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">Actions</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href={`/persons/${person.id}/edit`} className="flex items-center">
<Pencil className="mr-2 h-4 w-4" />
<span>Modifier</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/persons/${person.id}/tags`} className="flex items-center">
<Tag className="mr-2 h-4 w-4" />
<span>Gérer les tags</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem className="text-destructive focus:text-destructive">
<Trash2 className="mr-2 h-4 w-4" />
<span>Supprimer</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
);
}

View File

@ -0,0 +1,186 @@
"use client";
import { useState, useEffect } from "react";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import { useForm } from "react-hook-form";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
ArrowLeft,
Loader2,
Save
} from "lucide-react";
import { toast } from "sonner";
// Type definitions
interface ProjectFormData {
name: string;
description: string;
}
// Mock project data
const getProjectData = (id: string) => {
return {
id: parseInt(id),
name: "Projet Formation Dev Web",
description: "Création de groupes pour la formation développement web",
date: "2025-05-15",
};
};
export default function EditProjectPage() {
const params = useParams();
const router = useRouter();
const projectId = params.id as string;
const [project, setProject] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const { register, handleSubmit, formState: { errors }, reset } = useForm<ProjectFormData>();
useEffect(() => {
// Simulate API call to fetch project data
const fetchProject = async () => {
setLoading(true);
try {
// In a real app, this would be an API call
await new Promise(resolve => setTimeout(resolve, 1000));
const data = getProjectData(projectId);
setProject(data);
// Reset form with project data
reset({
name: data.name,
description: data.description
});
} catch (error) {
console.error("Error fetching project:", error);
toast.error("Erreur lors du chargement du projet");
} finally {
setLoading(false);
}
};
fetchProject();
}, [projectId, reset]);
const onSubmit = async (data: ProjectFormData) => {
setIsSubmitting(true);
try {
// In a real app, this would be an API call to update the project
await new Promise(resolve => setTimeout(resolve, 1000));
toast.success("Projet mis à jour avec succès");
router.push(`/projects/${projectId}`);
} catch (error) {
console.error("Error updating project:", error);
toast.error("Erreur lors de la mise à jour du projet");
} finally {
setIsSubmitting(false);
}
};
if (loading) {
return (
<div className="flex h-[50vh] items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
);
}
if (!project) {
return (
<div className="flex h-[50vh] flex-col items-center justify-center">
<p className="text-lg font-medium">Projet non trouvé</p>
<Button asChild className="mt-4">
<Link href="/projects">Retour aux projets</Link>
</Button>
</div>
);
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-2">
<Button variant="outline" size="icon" asChild>
<Link href={`/projects/${projectId}`}>
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
<h1 className="text-3xl font-bold">Modifier le projet</h1>
</div>
<Card>
<form onSubmit={handleSubmit(onSubmit)}>
<CardHeader>
<CardTitle>Informations du projet</CardTitle>
<CardDescription>
Modifiez les informations de votre projet
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Nom du projet</Label>
<Input
id="name"
placeholder="Ex: Formation Développement Web"
{...register("name", {
required: "Le nom du projet est requis",
minLength: {
value: 3,
message: "Le nom doit contenir au moins 3 caractères"
}
})}
/>
{errors.name && (
<p className="text-sm text-destructive">{errors.name.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
placeholder="Décrivez votre projet..."
rows={4}
{...register("description", {
required: "La description du projet est requise",
minLength: {
value: 10,
message: "La description doit contenir au moins 10 caractères"
}
})}
/>
{errors.description && (
<p className="text-sm text-destructive">{errors.description.message}</p>
)}
</div>
</CardContent>
<CardFooter className="flex justify-between">
<Button variant="outline" asChild>
<Link href={`/projects/${projectId}`}>Annuler</Link>
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Enregistrement...
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
Enregistrer les modifications
</>
)}
</Button>
</CardFooter>
</form>
</Card>
</div>
);
}

View File

@ -0,0 +1,432 @@
"use client";
import { useState, useEffect } from "react";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle, CardFooter } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Slider } from "@/components/ui/slider";
import { Switch } from "@/components/ui/switch";
import {
ArrowLeft,
Loader2,
Wand2,
Save,
RefreshCw
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { toast } from "sonner";
// Mock project data (same as in the groups page)
const getProjectData = (id: string) => {
return {
id: parseInt(id),
name: "Projet Formation Dev Web",
description: "Création de groupes pour la formation développement web",
date: "2025-05-15",
persons: [
{ id: 1, name: "Jean Dupont", tags: ["Frontend", "React", "Junior"] },
{ id: 2, name: "Marie Martin", tags: ["Backend", "Node.js", "Senior"] },
{ id: 3, name: "Pierre Durand", tags: ["Fullstack", "JavaScript", "Medior"] },
{ id: 4, name: "Sophie Lefebvre", tags: ["UX/UI", "Design", "Senior"] },
{ id: 5, name: "Thomas Bernard", tags: ["Backend", "Java", "Senior"] },
{ id: 6, name: "Julie Petit", tags: ["Frontend", "Vue", "Junior"] },
{ id: 7, name: "Nicolas Moreau", tags: ["DevOps", "Docker", "Medior"] },
{ id: 8, name: "Emma Dubois", tags: ["Frontend", "Angular", "Junior"] },
{ id: 9, name: "Lucas Leroy", tags: ["Backend", "Python", "Medior"] },
{ id: 10, name: "Camille Roux", tags: ["Fullstack", "TypeScript", "Senior"] },
{ id: 11, name: "Hugo Fournier", tags: ["Frontend", "React", "Medior"] },
{ id: 12, name: "Léa Girard", tags: ["UX/UI", "Figma", "Junior"] },
{ id: 13, name: "Mathis Bonnet", tags: ["Backend", "PHP", "Junior"] },
{ id: 14, name: "Chloé Lambert", tags: ["Frontend", "CSS", "Senior"] },
{ id: 15, name: "Nathan Mercier", tags: ["DevOps", "Kubernetes", "Senior"] },
{ id: 16, name: "Zoé Faure", tags: ["Fullstack", "MERN", "Medior"] },
]
};
};
// Type definitions
interface Person {
id: number;
name: string;
tags: string[];
}
interface Group {
id: number;
name: string;
persons: Person[];
}
export default function AutoCreateGroupsPage() {
const params = useParams();
const router = useRouter();
const projectId = params.id as string;
const [project, setProject] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [generating, setGenerating] = useState(false);
const [saving, setSaving] = useState(false);
// State for auto-generation parameters
const [numberOfGroups, setNumberOfGroups] = useState(4);
const [balanceTags, setBalanceTags] = useState(true);
const [balanceLevels, setBalanceLevels] = useState(true);
const [groups, setGroups] = useState<Group[]>([]);
const [availableTags, setAvailableTags] = useState<string[]>([]);
const [availableLevels, setAvailableLevels] = useState<string[]>([]);
useEffect(() => {
// Simulate API call to fetch project data
const fetchProject = async () => {
setLoading(true);
try {
// In a real app, this would be an API call
await new Promise(resolve => setTimeout(resolve, 1000));
const data = getProjectData(projectId);
setProject(data);
// Extract unique tags and levels
const tags = new Set<string>();
const levels = new Set<string>();
data.persons.forEach(person => {
person.tags.forEach(tag => {
// Assuming the last tag is the level (Junior, Medior, Senior)
if (["Junior", "Medior", "Senior"].includes(tag)) {
levels.add(tag);
} else {
tags.add(tag);
}
});
});
setAvailableTags(Array.from(tags));
setAvailableLevels(Array.from(levels));
} catch (error) {
console.error("Error fetching project:", error);
toast.error("Erreur lors du chargement du projet");
} finally {
setLoading(false);
}
};
fetchProject();
}, [projectId]);
const generateGroups = async () => {
if (!project) return;
setGenerating(true);
try {
// In a real app, this would be an API call to the backend
// which would run the algorithm to create balanced groups
await new Promise(resolve => setTimeout(resolve, 1500));
// Simple algorithm to create balanced groups
const persons = [...project.persons];
const newGroups: Group[] = [];
// Create empty groups
for (let i = 0; i < numberOfGroups; i++) {
newGroups.push({
id: i + 1,
name: `Groupe ${String.fromCharCode(65 + i)}`, // A, B, C, ...
persons: []
});
}
// Sort persons by level if balancing levels
if (balanceLevels) {
persons.sort((a, b) => {
const aLevel = a.tags.find((tag: string) => ["Junior", "Medior", "Senior"].includes(tag)) || "";
const bLevel = b.tags.find((tag: string) => ["Junior", "Medior", "Senior"].includes(tag)) || "";
// Order: Senior, Medior, Junior
const levelOrder: Record<string, number> = { "Senior": 0, "Medior": 1, "Junior": 2 };
return levelOrder[aLevel] - levelOrder[bLevel];
});
}
// Sort persons by tags if balancing tags
if (balanceTags) {
// Group persons by their primary skill tag
const personsByTag: Record<string, Person[]> = {};
persons.forEach(person => {
// Get first tag that's not a level
const primaryTag = person.tags.find((tag: string) => !["Junior", "Medior", "Senior"].includes(tag));
if (primaryTag) {
if (!personsByTag[primaryTag]) {
personsByTag[primaryTag] = [];
}
personsByTag[primaryTag].push(person);
}
});
// Distribute persons from each tag group evenly
let currentGroupIndex = 0;
Object.values(personsByTag).forEach(tagPersons => {
tagPersons.forEach(person => {
newGroups[currentGroupIndex].persons.push(person);
currentGroupIndex = (currentGroupIndex + 1) % numberOfGroups;
});
});
} else {
// Simple distribution without balancing tags
persons.forEach((person, index) => {
const groupIndex = index % numberOfGroups;
newGroups[groupIndex].persons.push(person);
});
}
setGroups(newGroups);
toast.success("Groupes générés avec succès");
} catch (error) {
console.error("Error generating groups:", error);
toast.error("Erreur lors de la génération des groupes");
} finally {
setGenerating(false);
}
};
const handleSaveGroups = async () => {
if (groups.length === 0) {
toast.error("Veuillez d'abord générer des groupes");
return;
}
setSaving(true);
try {
// In a real app, this would be an API call to save the groups
await new Promise(resolve => setTimeout(resolve, 1000));
toast.success("Groupes enregistrés avec succès");
// Navigate back to the groups page
router.push(`/projects/${projectId}/groups`);
} catch (error) {
console.error("Error saving groups:", error);
toast.error("Erreur lors de l'enregistrement des groupes");
} finally {
setSaving(false);
}
};
if (loading) {
return (
<div className="flex h-[50vh] items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
);
}
if (!project) {
return (
<div className="flex h-[50vh] flex-col items-center justify-center">
<p className="text-lg font-medium">Projet non trouvé</p>
<Button asChild className="mt-4">
<Link href="/projects">Retour aux projets</Link>
</Button>
</div>
);
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Button variant="outline" size="icon" asChild>
<Link href={`/projects/${projectId}/groups`}>
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
<h1 className="text-3xl font-bold">Assistant de création de groupes</h1>
</div>
<Button onClick={handleSaveGroups} disabled={saving || groups.length === 0}>
{saving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Enregistrement...
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
Enregistrer les groupes
</>
)}
</Button>
</div>
<div className="grid gap-6 md:grid-cols-[1fr_2fr]">
{/* Parameters */}
<Card>
<CardHeader>
<CardTitle>Paramètres</CardTitle>
<CardDescription>
Configurez les paramètres pour la génération automatique de groupes
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Label htmlFor="number-of-groups">Nombre de groupes: {numberOfGroups}</Label>
<Slider
id="number-of-groups"
min={2}
max={Math.min(8, Math.floor(project.persons.length / 2))}
step={1}
value={[numberOfGroups]}
onValueChange={(value) => setNumberOfGroups(value[0])}
/>
<p className="text-xs text-muted-foreground">
{Math.ceil(project.persons.length / numberOfGroups)} personnes par groupe en moyenne
</p>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="balance-tags">Équilibrer les compétences</Label>
<p className="text-xs text-muted-foreground">
Répartir équitablement les compétences dans chaque groupe
</p>
</div>
<Switch
id="balance-tags"
checked={balanceTags}
onCheckedChange={setBalanceTags}
/>
</div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="balance-levels">Équilibrer les niveaux</Label>
<p className="text-xs text-muted-foreground">
Répartir équitablement les niveaux (Junior, Medior, Senior) dans chaque groupe
</p>
</div>
<Switch
id="balance-levels"
checked={balanceLevels}
onCheckedChange={setBalanceLevels}
/>
</div>
</div>
<div className="space-y-2">
<Label>Compétences disponibles</Label>
<div className="flex flex-wrap gap-1">
{availableTags.map((tag, index) => (
<Badge key={index} variant="outline">
{tag}
</Badge>
))}
</div>
</div>
<div className="space-y-2">
<Label>Niveaux disponibles</Label>
<div className="flex flex-wrap gap-1">
{availableLevels.map((level, index) => (
<Badge key={index} variant="outline">
{level}
</Badge>
))}
</div>
</div>
</CardContent>
<CardFooter>
<Button
onClick={generateGroups}
disabled={generating}
className="w-full"
>
{generating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Génération en cours...
</>
) : (
<>
<Wand2 className="mr-2 h-4 w-4" />
Générer les groupes
</>
)}
</Button>
</CardFooter>
</Card>
{/* Generated Groups */}
<div className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Groupes générés</CardTitle>
<CardDescription>
{groups.length > 0
? `${groups.length} groupes avec ${project.persons.length} personnes au total`
: "Utilisez les paramètres à gauche pour générer des groupes"}
</CardDescription>
</CardHeader>
<CardContent>
{groups.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8">
<Wand2 className="h-12 w-12 text-muted-foreground mb-4" />
<p className="text-center text-muted-foreground">
Aucun groupe généré. Cliquez sur "Générer les groupes" pour commencer.
</p>
</div>
) : (
<div className="space-y-4">
{groups.map((group) => (
<Card key={group.id}>
<CardHeader className="pb-2">
<CardTitle>{group.name}</CardTitle>
<CardDescription>
{group.persons.length} personnes
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
{group.persons.map((person) => (
<div key={person.id} className="flex items-center justify-between border-b pb-2 last:border-0 last:pb-0">
<div>
<p className="font-medium">{person.name}</p>
<div className="flex flex-wrap gap-1 mt-1">
{person.tags.map((tag, index) => (
<Badge key={index} variant="outline" className="text-xs">
{tag}
</Badge>
))}
</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
))}
</div>
)}
</CardContent>
{groups.length > 0 && (
<CardFooter>
<Button
variant="outline"
onClick={generateGroups}
disabled={generating}
className="w-full"
>
<RefreshCw className="mr-2 h-4 w-4" />
Régénérer les groupes
</Button>
</CardFooter>
)}
</Card>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,379 @@
"use client";
import { useState, useEffect } from "react";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
ArrowLeft,
Loader2,
Save,
Plus,
X
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { toast } from "sonner";
// Mock project data (same as in the groups page)
const getProjectData = (id: string) => {
return {
id: parseInt(id),
name: "Projet Formation Dev Web",
description: "Création de groupes pour la formation développement web",
date: "2025-05-15",
persons: [
{ id: 1, name: "Jean Dupont", tags: ["Frontend", "React", "Junior"] },
{ id: 2, name: "Marie Martin", tags: ["Backend", "Node.js", "Senior"] },
{ id: 3, name: "Pierre Durand", tags: ["Fullstack", "JavaScript", "Medior"] },
{ id: 4, name: "Sophie Lefebvre", tags: ["UX/UI", "Design", "Senior"] },
{ id: 5, name: "Thomas Bernard", tags: ["Backend", "Java", "Senior"] },
{ id: 6, name: "Julie Petit", tags: ["Frontend", "Vue", "Junior"] },
{ id: 7, name: "Nicolas Moreau", tags: ["DevOps", "Docker", "Medior"] },
{ id: 8, name: "Emma Dubois", tags: ["Frontend", "Angular", "Junior"] },
{ id: 9, name: "Lucas Leroy", tags: ["Backend", "Python", "Medior"] },
{ id: 10, name: "Camille Roux", tags: ["Fullstack", "TypeScript", "Senior"] },
{ id: 11, name: "Hugo Fournier", tags: ["Frontend", "React", "Medior"] },
{ id: 12, name: "Léa Girard", tags: ["UX/UI", "Figma", "Junior"] },
{ id: 13, name: "Mathis Bonnet", tags: ["Backend", "PHP", "Junior"] },
{ id: 14, name: "Chloé Lambert", tags: ["Frontend", "CSS", "Senior"] },
{ id: 15, name: "Nathan Mercier", tags: ["DevOps", "Kubernetes", "Senior"] },
{ id: 16, name: "Zoé Faure", tags: ["Fullstack", "MERN", "Medior"] },
]
};
};
// Type definitions
interface Person {
id: number;
name: string;
tags: string[];
}
interface Group {
id: number;
name: string;
persons: Person[];
}
export default function CreateGroupsPage() {
const params = useParams();
const router = useRouter();
const projectId = params.id as string;
const [project, setProject] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
// State for groups and available persons
const [groups, setGroups] = useState<Group[]>([]);
const [availablePersons, setAvailablePersons] = useState<Person[]>([]);
const [newGroupName, setNewGroupName] = useState("");
// State for drag and drop
const [draggedPerson, setDraggedPerson] = useState<Person | null>(null);
const [draggedFromGroup, setDraggedFromGroup] = useState<number | null>(null);
const [dragOverGroup, setDragOverGroup] = useState<number | null>(null);
useEffect(() => {
// Simulate API call to fetch project data
const fetchProject = async () => {
setLoading(true);
try {
// In a real app, this would be an API call
await new Promise(resolve => setTimeout(resolve, 1000));
const data = getProjectData(projectId);
setProject(data);
setAvailablePersons(data.persons);
} catch (error) {
console.error("Error fetching project:", error);
toast.error("Erreur lors du chargement du projet");
} finally {
setLoading(false);
}
};
fetchProject();
}, [projectId]);
const handleAddGroup = () => {
if (!newGroupName.trim()) {
toast.error("Veuillez entrer un nom de groupe");
return;
}
const newGroup: Group = {
id: Date.now(), // Use timestamp as temporary ID
name: newGroupName,
persons: []
};
setGroups([...groups, newGroup]);
setNewGroupName("");
};
const handleRemoveGroup = (groupId: number) => {
const group = groups.find(g => g.id === groupId);
if (group) {
// Return persons from this group to available persons
setAvailablePersons([...availablePersons, ...group.persons]);
}
setGroups(groups.filter(g => g.id !== groupId));
};
const handleDragStart = (person: Person, fromGroup: number | null) => {
setDraggedPerson(person);
setDraggedFromGroup(fromGroup);
};
const handleDragOver = (e: React.DragEvent, toGroup: number | null) => {
e.preventDefault();
setDragOverGroup(toGroup);
};
const handleDrop = (e: React.DragEvent, toGroup: number | null) => {
e.preventDefault();
if (!draggedPerson) return;
// Remove person from source
if (draggedFromGroup === null) {
// From available persons
setAvailablePersons(availablePersons.filter(p => p.id !== draggedPerson.id));
} else {
// From another group
const sourceGroup = groups.find(g => g.id === draggedFromGroup);
if (sourceGroup) {
const updatedGroups = groups.map(g => {
if (g.id === draggedFromGroup) {
return {
...g,
persons: g.persons.filter(p => p.id !== draggedPerson.id)
};
}
return g;
});
setGroups(updatedGroups);
}
}
// Add person to destination
if (toGroup === null) {
// To available persons
setAvailablePersons([...availablePersons, draggedPerson]);
} else {
// To a group
const updatedGroups = groups.map(g => {
if (g.id === toGroup) {
return {
...g,
persons: [...g.persons, draggedPerson]
};
}
return g;
});
setGroups(updatedGroups);
}
// Reset drag state
setDraggedPerson(null);
setDraggedFromGroup(null);
setDragOverGroup(null);
};
const handleSaveGroups = async () => {
setSaving(true);
try {
// Validate that all groups have at least one person
const emptyGroups = groups.filter(g => g.persons.length === 0);
if (emptyGroups.length > 0) {
toast.error(`${emptyGroups.length} groupe(s) vide(s). Veuillez ajouter des personnes à tous les groupes.`);
setSaving(false);
return;
}
// In a real app, this would be an API call to save the groups
await new Promise(resolve => setTimeout(resolve, 1000));
toast.success("Groupes enregistrés avec succès");
// Navigate back to the groups page
router.push(`/projects/${projectId}/groups`);
} catch (error) {
console.error("Error saving groups:", error);
toast.error("Erreur lors de l'enregistrement des groupes");
} finally {
setSaving(false);
}
};
if (loading) {
return (
<div className="flex h-[50vh] items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
);
}
if (!project) {
return (
<div className="flex h-[50vh] flex-col items-center justify-center">
<p className="text-lg font-medium">Projet non trouvé</p>
<Button asChild className="mt-4">
<Link href="/projects">Retour aux projets</Link>
</Button>
</div>
);
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Button variant="outline" size="icon" asChild>
<Link href={`/projects/${projectId}/groups`}>
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
<h1 className="text-3xl font-bold">Créer des groupes</h1>
</div>
<Button onClick={handleSaveGroups} disabled={saving || groups.length === 0}>
{saving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Enregistrement...
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
Enregistrer les groupes
</>
)}
</Button>
</div>
<div className="grid gap-6 md:grid-cols-[1fr_2fr]">
{/* Available persons */}
<div
className={`border rounded-lg p-4 ${dragOverGroup === null ? 'bg-muted/50' : ''}`}
onDragOver={(e) => handleDragOver(e, null)}
onDrop={(e) => handleDrop(e, null)}
>
<h2 className="text-xl font-bold mb-4">Personnes disponibles ({availablePersons.length})</h2>
<div className="space-y-2">
{availablePersons.map(person => (
<div
key={person.id}
className="border rounded-md p-3 bg-card cursor-move"
draggable
onDragStart={() => handleDragStart(person, null)}
>
<p className="font-medium">{person.name}</p>
<div className="flex flex-wrap gap-1 mt-1">
{person.tags.map((tag, index) => (
<Badge key={index} variant="outline" className="text-xs">
{tag}
</Badge>
))}
</div>
</div>
))}
{availablePersons.length === 0 && (
<p className="text-muted-foreground text-center py-4">
Toutes les personnes ont é assignées à des groupes
</p>
)}
</div>
</div>
{/* Groups */}
<div className="space-y-4">
{/* Add new group form */}
<Card>
<CardHeader>
<CardTitle>Ajouter un nouveau groupe</CardTitle>
</CardHeader>
<CardContent>
<div className="flex gap-2">
<div className="flex-1">
<Label htmlFor="group-name" className="sr-only">Nom du groupe</Label>
<Input
id="group-name"
placeholder="Nom du groupe"
value={newGroupName}
onChange={(e) => setNewGroupName(e.target.value)}
/>
</div>
<Button onClick={handleAddGroup}>
<Plus className="h-4 w-4 mr-2" />
Ajouter
</Button>
</div>
</CardContent>
</Card>
{/* Groups list */}
{groups.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-8">
<p className="text-muted-foreground text-center mb-4">
Aucun groupe créé. Commencez par ajouter un groupe.
</p>
</CardContent>
</Card>
) : (
<div className="space-y-4">
{groups.map(group => (
<Card
key={group.id}
className={dragOverGroup === group.id ? 'border-primary' : ''}
onDragOver={(e) => handleDragOver(e, group.id)}
onDrop={(e) => handleDrop(e, group.id)}
>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle>{group.name}</CardTitle>
<Button
variant="ghost"
size="icon"
onClick={() => handleRemoveGroup(group.id)}
className="h-8 w-8 text-destructive"
>
<X className="h-4 w-4" />
</Button>
</CardHeader>
<CardContent>
<div className="space-y-2">
{group.persons.map(person => (
<div
key={person.id}
className="border rounded-md p-3 bg-card cursor-move"
draggable
onDragStart={() => handleDragStart(person, group.id)}
>
<p className="font-medium">{person.name}</p>
<div className="flex flex-wrap gap-1 mt-1">
{person.tags.map((tag, index) => (
<Badge key={index} variant="outline" className="text-xs">
{tag}
</Badge>
))}
</div>
</div>
))}
{group.persons.length === 0 && (
<div className="border border-dashed rounded-md p-4 text-center text-muted-foreground">
Glissez-déposez des personnes ici
</div>
)}
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,256 @@
"use client";
import { useState, useEffect } from "react";
import { useParams } from "next/navigation";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
PlusCircle,
Users,
Wand2,
ArrowLeft,
Loader2
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { toast } from "sonner";
// Mock project data
const getProjectData = (id: string) => {
return {
id: parseInt(id),
name: "Projet Formation Dev Web",
description: "Création de groupes pour la formation développement web",
date: "2025-05-15",
groups: [
{
id: 1,
name: "Groupe A",
persons: [
{ id: 1, name: "Jean Dupont", tags: ["Frontend", "React", "Junior"] },
{ id: 2, name: "Marie Martin", tags: ["Backend", "Node.js", "Senior"] },
{ id: 3, name: "Pierre Durand", tags: ["Fullstack", "JavaScript", "Medior"] },
{ id: 4, name: "Sophie Lefebvre", tags: ["UX/UI", "Design", "Senior"] },
]
},
{
id: 2,
name: "Groupe B",
persons: [
{ id: 5, name: "Thomas Bernard", tags: ["Backend", "Java", "Senior"] },
{ id: 6, name: "Julie Petit", tags: ["Frontend", "Vue", "Junior"] },
{ id: 7, name: "Nicolas Moreau", tags: ["DevOps", "Docker", "Medior"] },
{ id: 8, name: "Emma Dubois", tags: ["Frontend", "Angular", "Junior"] },
]
},
{
id: 3,
name: "Groupe C",
persons: [
{ id: 9, name: "Lucas Leroy", tags: ["Backend", "Python", "Medior"] },
{ id: 10, name: "Camille Roux", tags: ["Fullstack", "TypeScript", "Senior"] },
{ id: 11, name: "Hugo Fournier", tags: ["Frontend", "React", "Medior"] },
{ id: 12, name: "Léa Girard", tags: ["UX/UI", "Figma", "Junior"] },
]
},
{
id: 4,
name: "Groupe D",
persons: [
{ id: 13, name: "Mathis Bonnet", tags: ["Backend", "PHP", "Junior"] },
{ id: 14, name: "Chloé Lambert", tags: ["Frontend", "CSS", "Senior"] },
{ id: 15, name: "Nathan Mercier", tags: ["DevOps", "Kubernetes", "Senior"] },
{ id: 16, name: "Zoé Faure", tags: ["Fullstack", "MERN", "Medior"] },
]
}
],
persons: [
{ id: 1, name: "Jean Dupont", tags: ["Frontend", "React", "Junior"] },
{ id: 2, name: "Marie Martin", tags: ["Backend", "Node.js", "Senior"] },
{ id: 3, name: "Pierre Durand", tags: ["Fullstack", "JavaScript", "Medior"] },
{ id: 4, name: "Sophie Lefebvre", tags: ["UX/UI", "Design", "Senior"] },
{ id: 5, name: "Thomas Bernard", tags: ["Backend", "Java", "Senior"] },
{ id: 6, name: "Julie Petit", tags: ["Frontend", "Vue", "Junior"] },
{ id: 7, name: "Nicolas Moreau", tags: ["DevOps", "Docker", "Medior"] },
{ id: 8, name: "Emma Dubois", tags: ["Frontend", "Angular", "Junior"] },
{ id: 9, name: "Lucas Leroy", tags: ["Backend", "Python", "Medior"] },
{ id: 10, name: "Camille Roux", tags: ["Fullstack", "TypeScript", "Senior"] },
{ id: 11, name: "Hugo Fournier", tags: ["Frontend", "React", "Medior"] },
{ id: 12, name: "Léa Girard", tags: ["UX/UI", "Figma", "Junior"] },
{ id: 13, name: "Mathis Bonnet", tags: ["Backend", "PHP", "Junior"] },
{ id: 14, name: "Chloé Lambert", tags: ["Frontend", "CSS", "Senior"] },
{ id: 15, name: "Nathan Mercier", tags: ["DevOps", "Kubernetes", "Senior"] },
{ id: 16, name: "Zoé Faure", tags: ["Fullstack", "MERN", "Medior"] },
]
};
};
export default function ProjectGroupsPage() {
const params = useParams();
const projectId = params.id as string;
const [project, setProject] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState("existing");
useEffect(() => {
// Simulate API call to fetch project data
const fetchProject = async () => {
setLoading(true);
try {
// In a real app, this would be an API call
await new Promise(resolve => setTimeout(resolve, 1000));
const data = getProjectData(projectId);
setProject(data);
} catch (error) {
console.error("Error fetching project:", error);
toast.error("Erreur lors du chargement du projet");
} finally {
setLoading(false);
}
};
fetchProject();
}, [projectId]);
const handleCreateGroups = async () => {
toast.success("Redirection vers la page de création de groupes");
// In a real app, this would redirect to the group creation page
};
const handleAutoCreateGroups = async () => {
toast.success("Redirection vers l'assistant de création automatique de groupes");
// In a real app, this would redirect to the automatic group creation page
};
if (loading) {
return (
<div className="flex h-[50vh] items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
);
}
if (!project) {
return (
<div className="flex h-[50vh] flex-col items-center justify-center">
<p className="text-lg font-medium">Projet non trouvé</p>
<Button asChild className="mt-4">
<Link href="/projects">Retour aux projets</Link>
</Button>
</div>
);
}
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-2">
<Button variant="outline" size="icon" asChild>
<Link href={`/projects/${projectId}`}>
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
<h1 className="text-3xl font-bold">{project.name} - Groupes</h1>
</div>
<Tabs defaultValue="existing" className="space-y-4" onValueChange={setActiveTab}>
<TabsList>
<TabsTrigger value="existing">Groupes existants</TabsTrigger>
<TabsTrigger value="create">Créer des groupes</TabsTrigger>
</TabsList>
<TabsContent value="existing" className="space-y-4">
{project.groups.length === 0 ? (
<Card>
<CardHeader>
<CardTitle>Aucun groupe</CardTitle>
<CardDescription>
Ce projet ne contient pas encore de groupes. Créez-en un maintenant.
</CardDescription>
</CardHeader>
<CardContent className="flex justify-center py-6">
<Button onClick={handleCreateGroups}>
<PlusCircle className="mr-2 h-4 w-4" />
Créer un groupe
</Button>
</CardContent>
</Card>
) : (
<div className="grid gap-4 md:grid-cols-2">
{project.groups.map((group: any) => (
<Card key={group.id}>
<CardHeader>
<CardTitle>{group.name}</CardTitle>
<CardDescription>
{group.persons.length} personnes
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
{group.persons.map((person: any) => (
<div key={person.id} className="flex items-center justify-between border-b pb-2 last:border-0 last:pb-0">
<div>
<p className="font-medium">{person.name}</p>
<div className="flex flex-wrap gap-1 mt-1">
{person.tags.map((tag: string, index: number) => (
<Badge key={index} variant="outline" className="text-xs">
{tag}
</Badge>
))}
</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
))}
</div>
)}
</TabsContent>
<TabsContent value="create" className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Création manuelle</CardTitle>
<CardDescription>
Créez des groupes manuellement en glissant-déposant les personnes
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col items-center justify-center py-6">
<Users className="h-12 w-12 text-muted-foreground mb-4" />
<p className="text-center text-muted-foreground mb-4">
Utilisez l'interface de glisser-déposer pour créer vos groupes selon vos critères
</p>
<Button onClick={handleCreateGroups}>
<PlusCircle className="mr-2 h-4 w-4" />
Créer manuellement
</Button>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Création automatique</CardTitle>
<CardDescription>
Laissez l'assistant créer des groupes équilibrés automatiquement
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col items-center justify-center py-6">
<Wand2 className="h-12 w-12 text-muted-foreground mb-4" />
<p className="text-center text-muted-foreground mb-4">
L'assistant prendra en compte les tags et niveaux pour créer des groupes équilibrés
</p>
<Button onClick={handleAutoCreateGroups}>
<Wand2 className="mr-2 h-4 w-4" />
Utiliser l'assistant
</Button>
</CardContent>
</Card>
</div>
</TabsContent>
</Tabs>
</div>
);
}

View File

@ -0,0 +1,10 @@
import { DashboardLayout } from "@/components/dashboard-layout";
import { AuthLoading } from "@/components/auth-loading";
export default function ProjectsLayout({ children }: { children: React.ReactNode }) {
return (
<AuthLoading>
<DashboardLayout>{children}</DashboardLayout>
</AuthLoading>
);
}

View File

@ -0,0 +1,134 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { useForm } from "react-hook-form";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
ArrowLeft,
Loader2,
Save
} from "lucide-react";
import { toast } from "sonner";
// Type definitions
interface ProjectFormData {
name: string;
description: string;
}
export default function NewProjectPage() {
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const { register, handleSubmit, formState: { errors } } = useForm<ProjectFormData>({
defaultValues: {
name: "",
description: ""
}
});
const onSubmit = async (data: ProjectFormData) => {
setIsSubmitting(true);
try {
// In a real app, this would be an API call to create a new project
await new Promise(resolve => setTimeout(resolve, 1000));
// Simulate a successful response with a project ID
const projectId = Date.now();
toast.success("Projet créé avec succès");
router.push(`/projects/${projectId}`);
} catch (error) {
console.error("Error creating project:", error);
toast.error("Erreur lors de la création du projet");
} finally {
setIsSubmitting(false);
}
};
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-2">
<Button variant="outline" size="icon" asChild>
<Link href="/projects">
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
<h1 className="text-3xl font-bold">Nouveau projet</h1>
</div>
<Card>
<form onSubmit={handleSubmit(onSubmit)}>
<CardHeader>
<CardTitle>Informations du projet</CardTitle>
<CardDescription>
Créez un nouveau projet pour organiser vos groupes
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Nom du projet</Label>
<Input
id="name"
placeholder="Ex: Formation Développement Web"
{...register("name", {
required: "Le nom du projet est requis",
minLength: {
value: 3,
message: "Le nom doit contenir au moins 3 caractères"
}
})}
/>
{errors.name && (
<p className="text-sm text-destructive">{errors.name.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
placeholder="Décrivez votre projet..."
rows={4}
{...register("description", {
required: "La description du projet est requise",
minLength: {
value: 10,
message: "La description doit contenir au moins 10 caractères"
}
})}
/>
{errors.description && (
<p className="text-sm text-destructive">{errors.description.message}</p>
)}
</div>
</CardContent>
<CardFooter className="flex justify-between">
<Button variant="outline" asChild>
<Link href="/projects">Annuler</Link>
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Création en cours...
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
Créer le projet
</>
)}
</Button>
</CardFooter>
</form>
</Card>
</div>
);
}

View File

@ -0,0 +1,262 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from "@/components/ui/table";
import {
Card,
CardHeader,
CardTitle,
CardDescription,
CardContent,
CardFooter
} from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from "@/components/ui/dropdown-menu";
import {
PlusCircle,
Search,
MoreHorizontal,
Pencil,
Trash2,
Users,
Eye
} from "lucide-react";
export default function ProjectsPage() {
const [searchQuery, setSearchQuery] = useState("");
// Mock data for projects
const projects = [
{
id: 1,
name: "Projet Formation Dev Web",
description: "Création de groupes pour la formation développement web",
date: "2025-05-15",
groups: 4,
persons: 16,
},
{
id: 2,
name: "Projet Hackathon",
description: "Équipes pour le hackathon annuel",
date: "2025-05-10",
groups: 8,
persons: 32,
},
{
id: 3,
name: "Projet Workshop UX/UI",
description: "Groupes pour l'atelier UX/UI",
date: "2025-05-05",
groups: 5,
persons: 20,
},
{
id: 4,
name: "Projet Conférence Tech",
description: "Groupes pour la conférence technologique",
date: "2025-04-28",
groups: 6,
persons: 24,
},
{
id: 5,
name: "Projet Formation Data Science",
description: "Création de groupes pour la formation data science",
date: "2025-04-20",
groups: 3,
persons: 12,
},
];
// Filter projects based on search query
const filteredProjects = projects.filter(
(project) =>
project.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
project.description.toLowerCase().includes(searchQuery.toLowerCase())
);
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<h1 className="text-2xl sm:text-3xl font-bold">Projets</h1>
<Button asChild className="w-full sm:w-auto">
<Link href="/projects/new">
<PlusCircle className="mr-2 h-4 w-4" />
Nouveau projet
</Link>
</Button>
</div>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
type="search"
placeholder="Rechercher des projets..."
className="pl-8"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
</div>
{/* Mobile card view */}
<div className="grid gap-4 sm:hidden">
{filteredProjects.length === 0 ? (
<div className="rounded-md border p-6 text-center text-muted-foreground">
Aucun projet trouvé.
</div>
) : (
filteredProjects.map((project) => (
<Card key={project.id}>
<CardHeader className="pb-2">
<CardTitle className="text-lg">{project.name}</CardTitle>
<CardDescription>{project.description}</CardDescription>
</CardHeader>
<CardContent className="pb-2">
<div className="grid grid-cols-2 gap-2 text-sm">
<div className="flex flex-col">
<span className="text-muted-foreground">Date</span>
<span>{new Date(project.date).toLocaleDateString("fr-FR")}</span>
</div>
<div className="flex flex-col">
<span className="text-muted-foreground">Groupes</span>
<span>{project.groups}</span>
</div>
<div className="flex flex-col">
<span className="text-muted-foreground">Personnes</span>
<span>{project.persons}</span>
</div>
</div>
</CardContent>
<CardFooter className="flex justify-between pt-0">
<Button variant="outline" size="sm" asChild>
<Link href={`/projects/${project.id}`}>
<Eye className="mr-2 h-4 w-4" />
Voir
</Link>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm">
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">Actions</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href={`/projects/${project.id}/groups`} className="flex items-center">
<Users className="mr-2 h-4 w-4" />
<span>Gérer les groupes</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/projects/${project.id}/edit`} className="flex items-center">
<Pencil className="mr-2 h-4 w-4" />
<span>Modifier</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem className="text-destructive focus:text-destructive">
<Trash2 className="mr-2 h-4 w-4" />
<span>Supprimer</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</CardFooter>
</Card>
))
)}
</div>
{/* Desktop table view */}
<div className="rounded-md border hidden sm:block overflow-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead>Nom</TableHead>
<TableHead>Description</TableHead>
<TableHead>Date de création</TableHead>
<TableHead>Groupes</TableHead>
<TableHead>Personnes</TableHead>
<TableHead className="w-[100px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredProjects.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="h-24 text-center">
Aucun projet trouvé.
</TableCell>
</TableRow>
) : (
filteredProjects.map((project) => (
<TableRow key={project.id}>
<TableCell className="font-medium">{project.name}</TableCell>
<TableCell>{project.description}</TableCell>
<TableCell>{new Date(project.date).toLocaleDateString("fr-FR")}</TableCell>
<TableCell>{project.groups}</TableCell>
<TableCell>{project.persons}</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">Actions</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href={`/projects/${project.id}`} className="flex items-center">
<Eye className="mr-2 h-4 w-4" />
<span>Voir</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/projects/${project.id}/groups`} className="flex items-center">
<Users className="mr-2 h-4 w-4" />
<span>Gérer les groupes</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/projects/${project.id}/edit`} className="flex items-center">
<Pencil className="mr-2 h-4 w-4" />
<span>Modifier</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem className="text-destructive focus:text-destructive">
<Trash2 className="mr-2 h-4 w-4" />
<span>Supprimer</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
);
}

View File

@ -0,0 +1,10 @@
import { DashboardLayout } from "@/components/dashboard-layout";
import { AuthLoading } from "@/components/auth-loading";
export default function SettingsLayout({ children }: { children: React.ReactNode }) {
return (
<AuthLoading>
<DashboardLayout>{children}</DashboardLayout>
</AuthLoading>
);
}

View File

@ -0,0 +1,268 @@
"use client";
import { useState } from "react";
import { useForm } from "react-hook-form";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { toast } from "sonner";
export default function SettingsPage() {
const [activeTab, setActiveTab] = useState("profile");
const [isLoading, setIsLoading] = useState(false);
// Mock user data
const user = {
name: "Jean Dupont",
email: "jean.dupont@example.com",
avatar: "",
bio: "Développeur frontend passionné par les interfaces utilisateur et l'expérience utilisateur.",
notifications: {
email: true,
push: false,
projectUpdates: true,
groupChanges: true,
newMembers: false,
},
};
const { register, handleSubmit, formState: { errors } } = useForm({
defaultValues: {
name: user.name,
email: user.email,
bio: user.bio,
},
});
const onSubmitProfile = async (data: any) => {
setIsLoading(true);
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 1000));
setIsLoading(false);
toast.success("Profil mis à jour avec succès");
};
const onSubmitNotifications = async (data: any) => {
setIsLoading(true);
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 1000));
setIsLoading(false);
toast.success("Préférences de notification mises à jour avec succès");
};
const onExportData = async () => {
setIsLoading(true);
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 1000));
setIsLoading(false);
toast.success("Vos données ont été exportées. Vous recevrez un email avec le lien de téléchargement.");
};
const onDeleteAccount = async () => {
setIsLoading(true);
// Simulate API call
await new Promise((resolve) => setTimeout(resolve, 1000));
setIsLoading(false);
toast.success("Votre compte a été supprimé avec succès.");
};
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<h1 className="text-3xl font-bold">Paramètres</h1>
</div>
<Tabs defaultValue="profile" className="space-y-4" onValueChange={setActiveTab}>
<TabsList>
<TabsTrigger value="profile">Profil</TabsTrigger>
<TabsTrigger value="notifications">Notifications</TabsTrigger>
<TabsTrigger value="privacy">Confidentialité</TabsTrigger>
</TabsList>
<TabsContent value="profile" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Profil</CardTitle>
<CardDescription>
Gérez vos informations personnelles et votre profil.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center gap-4">
<Avatar className="h-16 w-16">
<AvatarImage src={user.avatar} alt={user.name} />
<AvatarFallback>{user.name.split(" ").map(n => n[0]).join("")}</AvatarFallback>
</Avatar>
<div>
<Button variant="outline" size="sm">
Changer d'avatar
</Button>
</div>
</div>
<Separator />
<form onSubmit={handleSubmit(onSubmitProfile)} className="space-y-4">
<div className="grid gap-2">
<Label htmlFor="name">Nom</Label>
<Input
id="name"
{...register("name", { required: "Le nom est requis" })}
/>
{errors.name && (
<p className="text-sm text-destructive">{errors.name.message}</p>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
{...register("email", {
required: "L'email est requis",
pattern: {
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
message: "Adresse email invalide"
}
})}
/>
{errors.email && (
<p className="text-sm text-destructive">{errors.email.message}</p>
)}
</div>
<div className="grid gap-2">
<Label htmlFor="bio">Bio</Label>
<Textarea
id="bio"
{...register("bio")}
rows={4}
/>
</div>
<Button type="submit" disabled={isLoading}>
{isLoading ? "Enregistrement..." : "Enregistrer les modifications"}
</Button>
</form>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="notifications" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Notifications</CardTitle>
<CardDescription>
Configurez vos préférences de notification.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="email-notifications">Notifications par email</Label>
<p className="text-sm text-muted-foreground">
Recevez des notifications par email.
</p>
</div>
<Switch id="email-notifications" defaultChecked={user.notifications.email} />
</div>
<Separator />
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="push-notifications">Notifications push</Label>
<p className="text-sm text-muted-foreground">
Recevez des notifications push dans votre navigateur.
</p>
</div>
<Switch id="push-notifications" defaultChecked={user.notifications.push} />
</div>
<Separator />
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="project-updates">Mises à jour de projets</Label>
<p className="text-sm text-muted-foreground">
Soyez notifié des mises à jour de vos projets.
</p>
</div>
<Switch id="project-updates" defaultChecked={user.notifications.projectUpdates} />
</div>
<Separator />
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="group-changes">Changements de groupes</Label>
<p className="text-sm text-muted-foreground">
Soyez notifié des changements dans vos groupes.
</p>
</div>
<Switch id="group-changes" defaultChecked={user.notifications.groupChanges} />
</div>
<Separator />
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label htmlFor="new-members">Nouveaux membres</Label>
<p className="text-sm text-muted-foreground">
Soyez notifié lorsque de nouveaux membres rejoignent vos projets.
</p>
</div>
<Switch id="new-members" defaultChecked={user.notifications.newMembers} />
</div>
</div>
</CardContent>
<CardFooter>
<Button onClick={onSubmitNotifications} disabled={isLoading}>
{isLoading ? "Enregistrement..." : "Enregistrer les préférences"}
</Button>
</CardFooter>
</Card>
</TabsContent>
<TabsContent value="privacy" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Confidentialité et données</CardTitle>
<CardDescription>
Gérez vos données personnelles et vos paramètres de confidentialité.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-4">
<div>
<h3 className="text-lg font-medium">Exporter vos données</h3>
<p className="text-sm text-muted-foreground">
Téléchargez une copie de vos données personnelles.
</p>
<Button
variant="outline"
className="mt-2"
onClick={onExportData}
disabled={isLoading}
>
{isLoading ? "Exportation..." : "Exporter mes données"}
</Button>
</div>
<Separator />
<div>
<h3 className="text-lg font-medium text-destructive">Supprimer votre compte</h3>
<p className="text-sm text-muted-foreground">
Supprimez définitivement votre compte et toutes vos données.
</p>
<Button
variant="destructive"
className="mt-2"
onClick={onDeleteAccount}
disabled={isLoading}
>
{isLoading ? "Suppression..." : "Supprimer mon compte"}
</Button>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
</div>
);
}

View File

@ -0,0 +1,277 @@
"use client";
import { useState, useEffect } from "react";
import { useParams, useRouter } from "next/navigation";
import Link from "next/link";
import { useForm, Controller } from "react-hook-form";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
ArrowLeft,
Loader2,
Save,
CircleDot
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { toast } from "sonner";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
// Type definitions
interface TagFormData {
name: string;
description: string;
color: string;
}
// Available colors
const colors = [
{ name: "Bleu", value: "blue" },
{ name: "Vert", value: "green" },
{ name: "Violet", value: "purple" },
{ name: "Rose", value: "pink" },
{ name: "Orange", value: "orange" },
{ name: "Jaune", value: "yellow" },
{ name: "Ambre", value: "amber" },
{ name: "Rouge", value: "red" },
];
// Map color names to Tailwind classes
const colorMap: Record<string, string> = {
blue: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300",
green: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300",
purple: "bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300",
pink: "bg-pink-100 text-pink-800 dark:bg-pink-900 dark:text-pink-300",
orange: "bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300",
yellow: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300",
amber: "bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-300",
red: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300",
};
// Mock tag data
const getTagData = (id: string) => {
return {
id: parseInt(id),
name: "Frontend",
description: "Développement frontend",
color: "blue",
persons: 12,
};
};
export default function EditTagPage() {
const params = useParams();
const router = useRouter();
const tagId = params.id as string;
const [tag, setTag] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
const { register, handleSubmit, control, watch, formState: { errors }, reset } = useForm<TagFormData>();
const selectedColor = watch("color");
useEffect(() => {
// Simulate API call to fetch tag data
const fetchTag = async () => {
setLoading(true);
try {
// In a real app, this would be an API call
await new Promise(resolve => setTimeout(resolve, 1000));
const data = getTagData(tagId);
setTag(data);
// Reset form with tag data
reset({
name: data.name,
description: data.description,
color: data.color
});
} catch (error) {
console.error("Error fetching tag:", error);
toast.error("Erreur lors du chargement du tag");
} finally {
setLoading(false);
}
};
fetchTag();
}, [tagId, reset]);
const onSubmit = async (data: TagFormData) => {
setIsSubmitting(true);
try {
// In a real app, this would be an API call to update the tag
await new Promise(resolve => setTimeout(resolve, 1000));
toast.success("Tag mis à jour avec succès");
router.push("/tags");
} catch (error) {
console.error("Error updating tag:", error);
toast.error("Erreur lors de la mise à jour du tag");
} finally {
setIsSubmitting(false);
}
};
if (loading) {
return (
<div className="flex h-[50vh] items-center justify-center">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
);
}
if (!tag) {
return (
<div className="flex h-[50vh] flex-col items-center justify-center">
<p className="text-lg font-medium">Tag non trouvé</p>
<Button asChild className="mt-4">
<Link href="/tags">Retour aux tags</Link>
</Button>
</div>
);
}
return (
<div className="flex flex-col gap-6">
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
<Button variant="outline" size="icon" asChild className="self-start">
<Link href="/tags">
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
<h1 className="text-2xl sm:text-3xl font-bold">Modifier le tag</h1>
</div>
<Card>
<form onSubmit={handleSubmit(onSubmit)}>
<CardHeader>
<CardTitle>Informations du tag</CardTitle>
<CardDescription>
Modifiez les informations du tag
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Nom du tag</Label>
<Input
id="name"
placeholder="Ex: Frontend"
{...register("name", {
required: "Le nom du tag est requis",
minLength: {
value: 2,
message: "Le nom doit contenir au moins 2 caractères"
}
})}
/>
{errors.name && (
<p className="text-sm text-destructive">{errors.name.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
placeholder="Ex: Développement frontend"
rows={3}
{...register("description", {
required: "La description du tag est requise",
minLength: {
value: 5,
message: "La description doit contenir au moins 5 caractères"
}
})}
/>
{errors.description && (
<p className="text-sm text-destructive">{errors.description.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="color">Couleur</Label>
<Controller
name="color"
control={control}
rules={{ required: "La couleur est requise" }}
render={({ field }) => (
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<SelectTrigger>
<SelectValue placeholder="Sélectionnez une couleur" />
</SelectTrigger>
<SelectContent>
{colors.map((color) => (
<SelectItem key={color.value} value={color.value}>
<div className="flex items-center">
<CircleDot className={`mr-2 h-4 w-4 text-${color.value}-500`} />
{color.name}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
{errors.color && (
<p className="text-sm text-destructive">{errors.color.message}</p>
)}
</div>
<div className="space-y-2">
<Label>Aperçu</Label>
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-2">
<Badge className={colorMap[selectedColor || tag.color]}>
{watch("name") || tag.name}
</Badge>
<span className="text-sm text-muted-foreground break-words">
{watch("description") || tag.description}
</span>
</div>
</div>
{tag.persons > 0 && (
<div className="rounded-md bg-muted p-3">
<p className="text-sm text-muted-foreground">
Ce tag est utilisé par {tag.persons} personne{tag.persons > 1 ? 's' : ''}.
La modification du tag affectera toutes ces personnes.
</p>
</div>
)}
</CardContent>
<CardFooter className="flex flex-col sm:flex-row gap-2 sm:justify-between">
<Button variant="outline" asChild className="w-full sm:w-auto order-2 sm:order-1">
<Link href="/tags">Annuler</Link>
</Button>
<Button type="submit" disabled={isSubmitting} className="w-full sm:w-auto order-1 sm:order-2">
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Enregistrement...
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
Enregistrer les modifications
</>
)}
</Button>
</CardFooter>
</form>
</Card>
</div>
);
}

View File

@ -0,0 +1,172 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { ArrowLeft } from "lucide-react";
import { TagSelector, Tag } from "@/components/tag-selector";
import { Label } from "@/components/ui/label";
export default function TagSelectorDemoPage() {
const [selectedTags, setSelectedTags] = useState<Tag[]>([]);
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-2">
<Button variant="outline" size="icon" asChild>
<Link href="/tags">
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
<h1 className="text-3xl font-bold">Démo du Sélecteur de Tags</h1>
</div>
<div className="grid gap-6 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle>Sélecteur de Tags</CardTitle>
<CardDescription>
Un composant réutilisable pour sélectionner plusieurs tags
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="tags">Tags</Label>
<TagSelector
selectedTags={selectedTags}
onChange={setSelectedTags}
placeholder="Sélectionner des tags..."
/>
</div>
</CardContent>
<CardFooter>
<p className="text-sm text-muted-foreground">
{selectedTags.length > 0
? `${selectedTags.length} tag${selectedTags.length > 1 ? "s" : ""} sélectionné${selectedTags.length > 1 ? "s" : ""}`
: "Aucun tag sélectionné"}
</p>
</CardFooter>
</Card>
<Card>
<CardHeader>
<CardTitle>Utilisation dans un formulaire</CardTitle>
<CardDescription>
Comment utiliser le sélecteur de tags dans un formulaire
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-md bg-muted p-4">
<pre className="text-xs overflow-auto">
{`// Importer le composant
import { TagSelector, Tag } from "@/components/tag-selector";
// Définir l'état pour les tags sélectionnés
const [selectedTags, setSelectedTags] = useState<Tag[]>([]);
// Utiliser le composant dans le formulaire
<div className="space-y-2">
<Label htmlFor="tags">Tags</Label>
<TagSelector
selectedTags={selectedTags}
onChange={setSelectedTags}
placeholder="Sélectionner des tags..."
/>
</div>
// Accéder aux tags sélectionnés
console.log(selectedTags);
`}
</pre>
</div>
</CardContent>
<CardFooter>
<div className="space-y-2 w-full">
<Label>Tags sélectionnés (données)</Label>
<div className="rounded-md bg-muted p-4 w-full overflow-auto">
<pre className="text-xs">
{JSON.stringify(selectedTags, null, 2)}
</pre>
</div>
</div>
</CardFooter>
</Card>
</div>
<Card>
<CardHeader>
<CardTitle>Exemple d'intégration</CardTitle>
<CardDescription>
Comment le sélecteur de tags peut être intégré dans différents formulaires
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<h3 className="text-lg font-medium">Formulaire de création de personne</h3>
<p className="text-sm text-muted-foreground">
Le sélecteur de tags peut être utilisé pour attribuer des compétences ou des caractéristiques à une personne.
</p>
<div className="rounded-md bg-muted p-4">
<pre className="text-xs overflow-auto">
{`// Dans le formulaire de création de personne
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Nom</Label>
<Input id="name" placeholder="John Doe" />
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input id="email" type="email" placeholder="john.doe@example.com" />
</div>
<div className="space-y-2">
<Label htmlFor="tags">Compétences / Caractéristiques</Label>
<TagSelector
selectedTags={selectedTags}
onChange={setSelectedTags}
placeholder="Sélectionner des compétences..."
/>
</div>
</div>`}
</pre>
</div>
</div>
<div className="space-y-2">
<h3 className="text-lg font-medium">Formulaire de création de projet</h3>
<p className="text-sm text-muted-foreground">
Le sélecteur de tags peut être utilisé pour catégoriser un projet.
</p>
<div className="rounded-md bg-muted p-4">
<pre className="text-xs overflow-auto">
{`// Dans le formulaire de création de projet
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Nom du projet</Label>
<Input id="name" placeholder="Projet Formation Dev Web" />
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea id="description" placeholder="Description du projet..." />
</div>
<div className="space-y-2">
<Label htmlFor="tags">Catégories</Label>
<TagSelector
selectedTags={selectedTags}
onChange={setSelectedTags}
placeholder="Sélectionner des catégories..."
/>
</div>
</div>`}
</pre>
</div>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@ -0,0 +1,10 @@
import { DashboardLayout } from "@/components/dashboard-layout";
import { AuthLoading } from "@/components/auth-loading";
export default function TagsLayout({ children }: { children: React.ReactNode }) {
return (
<AuthLoading>
<DashboardLayout>{children}</DashboardLayout>
</AuthLoading>
);
}

View File

@ -0,0 +1,215 @@
"use client";
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { useForm, Controller } from "react-hook-form";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
ArrowLeft,
Loader2,
Save,
CircleDot
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { toast } from "sonner";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
// Type definitions
interface TagFormData {
name: string;
description: string;
color: string;
}
// Available colors
const colors = [
{ name: "Bleu", value: "blue" },
{ name: "Vert", value: "green" },
{ name: "Violet", value: "purple" },
{ name: "Rose", value: "pink" },
{ name: "Orange", value: "orange" },
{ name: "Jaune", value: "yellow" },
{ name: "Ambre", value: "amber" },
{ name: "Rouge", value: "red" },
];
// Map color names to Tailwind classes
const colorMap: Record<string, string> = {
blue: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300",
green: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300",
purple: "bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300",
pink: "bg-pink-100 text-pink-800 dark:bg-pink-900 dark:text-pink-300",
orange: "bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300",
yellow: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300",
amber: "bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-300",
red: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300",
};
export default function NewTagPage() {
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const { register, handleSubmit, control, watch, formState: { errors } } = useForm<TagFormData>({
defaultValues: {
name: "",
description: "",
color: "blue"
}
});
const selectedColor = watch("color");
const onSubmit = async (data: TagFormData) => {
setIsSubmitting(true);
try {
// In a real app, this would be an API call to create a new tag
await new Promise(resolve => setTimeout(resolve, 1000));
// Simulate a successful response with a tag ID
const tagId = Date.now();
toast.success("Tag créé avec succès");
router.push("/tags");
} catch (error) {
console.error("Error creating tag:", error);
toast.error("Erreur lors de la création du tag");
} finally {
setIsSubmitting(false);
}
};
return (
<div className="flex flex-col gap-6">
<div className="flex items-center gap-2">
<Button variant="outline" size="icon" asChild>
<Link href="/tags">
<ArrowLeft className="h-4 w-4" />
</Link>
</Button>
<h1 className="text-3xl font-bold">Nouveau tag</h1>
</div>
<Card>
<form onSubmit={handleSubmit(onSubmit)}>
<CardHeader>
<CardTitle>Informations du tag</CardTitle>
<CardDescription>
Créez un nouveau tag pour catégoriser les personnes
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="name">Nom du tag</Label>
<Input
id="name"
placeholder="Ex: Frontend"
{...register("name", {
required: "Le nom du tag est requis",
minLength: {
value: 2,
message: "Le nom doit contenir au moins 2 caractères"
}
})}
/>
{errors.name && (
<p className="text-sm text-destructive">{errors.name.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
placeholder="Ex: Développement frontend"
rows={3}
{...register("description", {
required: "La description du tag est requise",
minLength: {
value: 5,
message: "La description doit contenir au moins 5 caractères"
}
})}
/>
{errors.description && (
<p className="text-sm text-destructive">{errors.description.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="color">Couleur</Label>
<Controller
name="color"
control={control}
rules={{ required: "La couleur est requise" }}
render={({ field }) => (
<Select
onValueChange={field.onChange}
defaultValue={field.value}
>
<SelectTrigger>
<SelectValue placeholder="Sélectionnez une couleur" />
</SelectTrigger>
<SelectContent>
{colors.map((color) => (
<SelectItem key={color.value} value={color.value}>
<div className="flex items-center">
<CircleDot className={`mr-2 h-4 w-4 text-${color.value}-500`} />
{color.name}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
)}
/>
{errors.color && (
<p className="text-sm text-destructive">{errors.color.message}</p>
)}
</div>
<div className="space-y-2">
<Label>Aperçu</Label>
<div className="flex items-center gap-2">
<Badge className={colorMap[selectedColor]}>
{watch("name") || "Nom du tag"}
</Badge>
<span className="text-sm text-muted-foreground">
{watch("description") || "Description du tag"}
</span>
</div>
</div>
</CardContent>
<CardFooter className="flex justify-between">
<Button variant="outline" asChild>
<Link href="/tags">Annuler</Link>
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Création en cours...
</>
) : (
<>
<Save className="mr-2 h-4 w-4" />
Créer le tag
</>
)}
</Button>
</CardFooter>
</form>
</Card>
</div>
);
}

212
frontend/app/tags/page.tsx Normal file
View File

@ -0,0 +1,212 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from "@/components/ui/table";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from "@/components/ui/dropdown-menu";
import {
PlusCircle,
Search,
MoreHorizontal,
Pencil,
Trash2,
Users
} from "lucide-react";
import { Badge } from "@/components/ui/badge";
export default function TagsPage() {
const [searchQuery, setSearchQuery] = useState("");
// Mock data for tags
const tags = [
{
id: 1,
name: "Frontend",
description: "Développement frontend",
color: "blue",
persons: 12,
},
{
id: 2,
name: "Backend",
description: "Développement backend",
color: "green",
persons: 8,
},
{
id: 3,
name: "Fullstack",
description: "Développement fullstack",
color: "purple",
persons: 5,
},
{
id: 4,
name: "UX/UI",
description: "Design UX/UI",
color: "pink",
persons: 3,
},
{
id: 5,
name: "DevOps",
description: "Infrastructure et déploiement",
color: "orange",
persons: 2,
},
{
id: 6,
name: "Junior",
description: "Niveau junior",
color: "yellow",
persons: 7,
},
{
id: 7,
name: "Medior",
description: "Niveau intermédiaire",
color: "amber",
persons: 5,
},
{
id: 8,
name: "Senior",
description: "Niveau senior",
color: "red",
persons: 6,
},
];
// Map color names to Tailwind classes
const colorMap: Record<string, string> = {
blue: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300",
green: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300",
purple: "bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300",
pink: "bg-pink-100 text-pink-800 dark:bg-pink-900 dark:text-pink-300",
orange: "bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300",
yellow: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300",
amber: "bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-300",
red: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300",
};
// Filter tags based on search query
const filteredTags = tags.filter(
(tag) =>
tag.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
tag.description.toLowerCase().includes(searchQuery.toLowerCase())
);
return (
<div className="flex flex-col gap-6">
<div className="flex items-center justify-between">
<h1 className="text-3xl font-bold">Tags</h1>
<div className="flex gap-2">
<Button variant="outline" asChild>
<Link href="/tags/demo">
Démo sélecteur
</Link>
</Button>
<Button asChild>
<Link href="/tags/new">
<PlusCircle className="mr-2 h-4 w-4" />
Nouveau tag
</Link>
</Button>
</div>
</div>
<div className="flex items-center gap-2">
<div className="relative flex-1">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
type="search"
placeholder="Rechercher des tags..."
className="pl-8"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
</div>
</div>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Nom</TableHead>
<TableHead>Description</TableHead>
<TableHead>Personnes</TableHead>
<TableHead className="w-[100px]">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredTags.length === 0 ? (
<TableRow>
<TableCell colSpan={4} className="h-24 text-center">
Aucun tag trouvé.
</TableCell>
</TableRow>
) : (
filteredTags.map((tag) => (
<TableRow key={tag.id}>
<TableCell>
<Badge className={colorMap[tag.color]}>
{tag.name}
</Badge>
</TableCell>
<TableCell>{tag.description}</TableCell>
<TableCell>{tag.persons}</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">Actions</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href={`/tags/${tag.id}/edit`} className="flex items-center">
<Pencil className="mr-2 h-4 w-4" />
<span>Modifier</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link href={`/tags/${tag.id}/persons`} className="flex items-center">
<Users className="mr-2 h-4 w-4" />
<span>Voir les personnes</span>
</Link>
</DropdownMenuItem>
<DropdownMenuItem className="text-destructive focus:text-destructive">
<Trash2 className="mr-2 h-4 w-4" />
<span>Supprimer</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
);
}

View File

@ -0,0 +1,140 @@
"use client";
import { ReactNode } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
LayoutDashboard,
Users,
Tags,
Settings,
LogOut,
Sun,
Moon,
Shield,
BarChart4
} from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarHeader,
SidebarMenu,
SidebarMenuItem,
SidebarMenuButton,
SidebarProvider,
SidebarTrigger,
} from "@/components/ui/sidebar";
import { useTheme } from "next-themes";
interface AdminLayoutProps {
children: ReactNode;
}
export function AdminLayout({ children }: AdminLayoutProps) {
const pathname = usePathname();
const { theme, setTheme } = useTheme();
const navigation = [
{
name: "Tableau de bord",
href: "/admin",
icon: LayoutDashboard,
},
{
name: "Utilisateurs",
href: "/admin/users",
icon: Users,
},
{
name: "Tags globaux",
href: "/admin/tags",
icon: Tags,
},
{
name: "Statistiques",
href: "/admin/stats",
icon: BarChart4,
},
{
name: "Paramètres système",
href: "/admin/settings",
icon: Settings,
},
];
return (
<SidebarProvider>
<div className="flex min-h-screen">
<Sidebar>
<SidebarHeader className="flex items-center justify-between">
<Link href="/" className="flex items-center gap-2 px-2">
<Shield className="h-5 w-5 text-primary" />
<span className="text-xl font-bold">Admin</span>
</Link>
<SidebarTrigger />
</SidebarHeader>
<SidebarContent>
<SidebarMenu>
{navigation.map((item) => (
<SidebarMenuItem key={item.href}>
<SidebarMenuButton
asChild
isActive={pathname === item.href}
tooltip={item.name}
>
<Link href={item.href} className="flex items-center">
<item.icon className="mr-2 h-5 w-5" />
<span>{item.name}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarContent>
<SidebarFooter>
<div className="flex flex-col gap-2 p-2">
<Button
variant="outline"
className="w-full justify-start"
asChild
>
<Link href="/dashboard">
<Users className="mr-2 h-5 w-5" />
Mode utilisateur
</Link>
</Button>
</div>
<div className="flex items-center justify-between px-4 py-2">
<Button
variant="ghost"
size="icon"
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
aria-label="Toggle theme"
>
{theme === "dark" ? (
<Sun className="h-5 w-5" />
) : (
<Moon className="h-5 w-5" />
)}
</Button>
<Button
variant="ghost"
size="icon"
aria-label="Logout"
asChild
>
<Link href="/auth/logout">
<LogOut className="h-5 w-5" />
</Link>
</Button>
</div>
</SidebarFooter>
</Sidebar>
<main className="flex-1 p-4 sm:p-6">{children}</main>
</div>
</SidebarProvider>
);
}

View File

@ -0,0 +1,24 @@
"use client";
import { useAuth } from "@/lib/auth-context";
import { Loader2 } from "lucide-react";
interface AuthLoadingProps {
children: React.ReactNode;
}
export function AuthLoading({ children }: AuthLoadingProps) {
const { isLoading } = useAuth();
if (isLoading) {
return (
<div className="flex min-h-screen flex-col items-center justify-center p-4 text-center">
<Loader2 className="mb-4 h-8 w-8 animate-spin text-primary" />
<h1 className="mb-2 text-xl font-semibold">Chargement...</h1>
<p className="text-muted-foreground">Veuillez patienter pendant que nous vérifions votre authentification.</p>
</div>
);
}
return <>{children}</>;
}

View File

@ -0,0 +1,168 @@
"use client";
import { ReactNode } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
LayoutDashboard,
Users,
FolderKanban,
Tags,
Settings,
LogOut,
Sun,
Moon,
Shield,
User
} from "lucide-react";
import { useAuth } from "@/lib/auth-context";
import { Button } from "@/components/ui/button";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarHeader,
SidebarMenu,
SidebarMenuItem,
SidebarMenuButton,
SidebarProvider,
SidebarTrigger,
} from "@/components/ui/sidebar";
import { useTheme } from "next-themes";
interface DashboardLayoutProps {
children: ReactNode;
}
export function DashboardLayout({ children }: DashboardLayoutProps) {
const pathname = usePathname();
const { theme, setTheme } = useTheme();
const { user, logout } = useAuth();
const navigation = [
{
name: "Tableau de bord",
href: "/dashboard",
icon: LayoutDashboard,
},
{
name: "Projets",
href: "/projects",
icon: FolderKanban,
},
{
name: "Personnes",
href: "/persons",
icon: Users,
},
{
name: "Tags",
href: "/tags",
icon: Tags,
},
{
name: "Paramètres",
href: "/settings",
icon: Settings,
},
];
return (
<SidebarProvider>
<div className="flex min-h-screen">
<Sidebar>
<SidebarHeader className="flex items-center justify-between">
<Link href="/" className="flex items-center gap-2 px-2">
<span className="text-xl font-bold">Groupes</span>
</Link>
<SidebarTrigger />
</SidebarHeader>
<SidebarContent>
<SidebarMenu>
{navigation.map((item) => (
<SidebarMenuItem key={item.href}>
<SidebarMenuButton
asChild
isActive={pathname === item.href}
tooltip={item.name}
>
<Link href={item.href} className="flex items-center">
<item.icon className="mr-2 h-5 w-5" />
<span>{item.name}</span>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
))}
</SidebarMenu>
</SidebarContent>
<SidebarFooter>
{/* User info */}
{user && (
<div className="flex items-center gap-3 p-4 border-b">
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-primary text-primary-foreground shrink-0">
{user.avatar ? (
<img
src={user.avatar}
alt={user.name}
className="h-8 w-8 rounded-full object-cover"
/>
) : (
<User className="h-4 w-4" />
)}
</div>
<div className="flex flex-col gap-0.5 min-w-0">
<span className="text-sm font-medium truncate">{user.name}</span>
<span className="text-xs text-muted-foreground">{user.role}</span>
</div>
</div>
)}
{/* Admin button */}
{user && user.role === 'ADMIN' && (
<div className="flex flex-col p-3">
<Button
variant="outline"
className="w-full justify-start"
asChild
>
<Link href="/admin" className="flex items-center">
<Shield className="mr-2 h-5 w-5" />
<span className="truncate">Mode administrateur</span>
</Link>
</Button>
</div>
)}
{/* Theme and logout buttons */}
<div className="flex items-center justify-between gap-2 px-4 py-3 mt-auto">
<Button
variant="ghost"
size="icon"
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
aria-label="Toggle theme"
className="flex items-center justify-center"
>
{theme === "dark" ? (
<Sun className="h-5 w-5" />
) : (
<Moon className="h-5 w-5" />
)}
</Button>
<Button
variant="ghost"
size="icon"
aria-label="Logout"
onClick={() => logout()}
className="flex items-center justify-center"
>
<LogOut className="h-5 w-5" />
</Button>
</div>
</SidebarFooter>
</Sidebar>
<main className="flex-1 p-4 sm:p-6">{children}</main>
</div>
</SidebarProvider>
);
}

View File

@ -0,0 +1,218 @@
"use client";
import { useState, useEffect } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem } from "@/components/ui/command";
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
import { Check, ChevronsUpDown, X } from "lucide-react";
import { cn } from "@/lib/utils";
// Map color names to Tailwind classes
const colorMap: Record<string, string> = {
blue: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300",
green: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300",
purple: "bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300",
pink: "bg-pink-100 text-pink-800 dark:bg-pink-900 dark:text-pink-300",
orange: "bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300",
yellow: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300",
amber: "bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-300",
red: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300",
};
export interface Tag {
id: number;
name: string;
description: string;
color: string;
}
interface TagSelectorProps {
selectedTags: Tag[];
onChange: (tags: Tag[]) => void;
placeholder?: string;
disabled?: boolean;
className?: string;
}
export function TagSelector({
selectedTags = [],
onChange,
placeholder = "Sélectionner des tags...",
disabled = false,
className,
}: TagSelectorProps) {
const [open, setOpen] = useState(false);
const [tags, setTags] = useState<Tag[]>([]);
const [loading, setLoading] = useState(true);
// Mock data for tags - in a real app, this would be fetched from an API
useEffect(() => {
const fetchTags = async () => {
setLoading(true);
try {
// Simulate API call
await new Promise(resolve => setTimeout(resolve, 500));
// Mock data
const mockTags = [
{
id: 1,
name: "Frontend",
description: "Développement frontend",
color: "blue",
},
{
id: 2,
name: "Backend",
description: "Développement backend",
color: "green",
},
{
id: 3,
name: "Fullstack",
description: "Développement fullstack",
color: "purple",
},
{
id: 4,
name: "UX/UI",
description: "Design UX/UI",
color: "pink",
},
{
id: 5,
name: "DevOps",
description: "Infrastructure et déploiement",
color: "orange",
},
{
id: 6,
name: "Junior",
description: "Niveau junior",
color: "yellow",
},
{
id: 7,
name: "Medior",
description: "Niveau intermédiaire",
color: "amber",
},
{
id: 8,
name: "Senior",
description: "Niveau senior",
color: "red",
},
];
setTags(mockTags);
} catch (error) {
console.error("Error fetching tags:", error);
} finally {
setLoading(false);
}
};
fetchTags();
}, []);
const handleSelect = (tag: Tag) => {
const isSelected = selectedTags.some(t => t.id === tag.id);
if (isSelected) {
onChange(selectedTags.filter(t => t.id !== tag.id));
} else {
onChange([...selectedTags, tag]);
}
};
const handleRemove = (tagId: number) => {
onChange(selectedTags.filter(tag => tag.id !== tagId));
};
return (
<div className={cn("space-y-2", className)}>
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={open}
className="w-full justify-between"
disabled={disabled || loading}
>
{selectedTags.length > 0
? `${selectedTags.length} tag${selectedTags.length > 1 ? "s" : ""} sélectionné${selectedTags.length > 1 ? "s" : ""}`
: placeholder}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="w-full p-0">
<Command>
<CommandInput placeholder="Rechercher un tag..." />
<CommandEmpty>Aucun tag trouvé.</CommandEmpty>
<CommandGroup className="max-h-64 overflow-auto">
{loading ? (
<div className="flex items-center justify-center p-4">
<span className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
) : (
tags.map(tag => (
<CommandItem
key={tag.id}
value={tag.name}
onSelect={() => handleSelect(tag)}
>
<Check
className={cn(
"mr-2 h-4 w-4",
selectedTags.some(t => t.id === tag.id)
? "opacity-100"
: "opacity-0"
)}
/>
<div className="flex items-center gap-2">
<Badge className={colorMap[tag.color]}>
{tag.name}
</Badge>
<span className="text-sm text-muted-foreground">
{tag.description}
</span>
</div>
</CommandItem>
))
)}
</CommandGroup>
</Command>
</PopoverContent>
</Popover>
{selectedTags.length > 0 && (
<div className="flex flex-wrap gap-2 mt-2">
{selectedTags.map(tag => (
<Badge
key={tag.id}
className={cn(
colorMap[tag.color],
"flex items-center gap-1 pr-1"
)}
>
{tag.name}
<Button
variant="ghost"
size="icon"
className="h-4 w-4 rounded-full p-0 hover:bg-background/20"
onClick={() => handleRemove(tag.id)}
disabled={disabled}
>
<X className="h-3 w-3" />
<span className="sr-only">Supprimer</span>
</Button>
</Badge>
))}
</div>
)}
</div>
);
}

View File

@ -0,0 +1,9 @@
"use client";
import * as React from "react";
import { ThemeProvider as NextThemesProvider } from "next-themes";
import { type ThemeProviderProps } from "next-themes";
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}

View File

@ -0,0 +1,91 @@
# Responsive Design Patterns
This document outlines the responsive design patterns used in the application to ensure a consistent user experience across different devices and screen sizes.
## Breakpoints
The application uses the following breakpoints, based on Tailwind CSS defaults:
- **sm**: 640px and up (small devices like large phones and small tablets)
- **md**: 768px and up (medium devices like tablets)
- **lg**: 1024px and up (large devices like desktops)
- **xl**: 1280px and up (extra large devices)
- **2xl**: 1536px and up (very large screens)
## Viewport Configuration
The application uses the following viewport configuration in the root layout:
```tsx
export const viewport: Viewport = {
width: "device-width",
initialScale: 1,
maximumScale: 1,
userScalable: true,
};
```
This ensures proper scaling on mobile devices while allowing users to zoom if needed.
## Layout Patterns
### Responsive Container
- Use `container` class with responsive padding: `px-4 md:px-6`
- Example: `<div className="container px-4 md:px-6">`
### Responsive Flexbox
- Stack elements vertically on small screens, horizontally on larger screens
- Example: `<div className="flex flex-col sm:flex-row">`
### Responsive Grid
- Single column on small screens, multiple columns on larger screens
- Example: `<div className="grid sm:grid-cols-2 lg:grid-cols-3">`
### Responsive Spacing
- Less padding on small screens, more on larger screens
- Example: `<main className="p-4 sm:p-6">`
## Component Patterns
### Responsive Typography
- Smaller font sizes on mobile, larger on desktop
- Example: `<h1 className="text-2xl sm:text-3xl font-bold">`
### Responsive Buttons
- Full-width on small screens, auto-width on larger screens
- Example: `<Button className="w-full sm:w-auto">`
### Responsive Tables
- Card layout on small screens, table layout on larger screens
- Hide less important columns on small screens
- Add horizontal scrolling for tables that don't fit
- Example: See the projects page implementation
### Responsive Forms
- Stack form actions on small screens, side-by-side on larger screens
- Adjust button order for mobile-first experience
- Example: `<CardFooter className="flex flex-col sm:flex-row gap-2">`
### Responsive Navigation
- Use a sidebar that collapses to an icon or off-canvas menu on small screens
- Use a dropdown or hamburger menu for mobile navigation
- Example: See the `Sidebar` component implementation
## Best Practices
1. **Mobile-First Approach**: Start with the mobile layout and progressively enhance for larger screens
2. **Consistent Patterns**: Use the same responsive patterns throughout the application
3. **Avoid Fixed Widths**: Use relative units (%, rem) and flexible layouts
4. **Test on Real Devices**: Verify the responsive design on actual devices, not just browser emulation
5. **Consider Touch Targets**: Make interactive elements large enough for touch (at least 44x44px)
6. **Optimize Images**: Use responsive images with appropriate sizes for different devices
7. **Performance**: Ensure the application performs well on mobile devices with potentially slower connections

301
frontend/lib/api.ts Normal file
View File

@ -0,0 +1,301 @@
/**
* API Service
*
* This service centralizes all API communication with the backend.
* It provides methods for authentication, projects, persons, and tags.
*/
const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000';
/**
* Base fetch function with error handling and authentication
*/
async function fetchAPI(endpoint: string, options: RequestInit = {}) {
// Set default headers
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options.headers as Record<string, string> || {}),
};
// Get token from localStorage if available (client-side only)
if (typeof window !== 'undefined') {
const token = localStorage.getItem('auth_token');
if (token) {
headers['Authorization'] = `Bearer ${token}`;
}
}
// Prepare fetch options
const fetchOptions: RequestInit = {
...options,
headers,
credentials: 'include', // Include cookies for session management
};
try {
const response = await fetch(`${API_URL}${endpoint}`, fetchOptions);
// Handle HTTP errors
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.message || `API error: ${response.status}`);
}
// Parse JSON response if content exists
const contentType = response.headers.get('content-type');
if (contentType && contentType.includes('application/json')) {
return await response.json();
}
return response;
} catch (error) {
console.error('API request failed:', error);
throw error;
}
}
/**
* Authentication API
*/
export const authAPI = {
/**
* Get GitHub OAuth URL
*/
getGitHubOAuthUrl: async () => {
return fetchAPI('/auth/github', { method: 'GET' });
},
/**
* Exchange code for access token
*/
githubCallback: async (code: string) => {
return fetchAPI('/auth/github/callback', {
method: 'POST',
body: JSON.stringify({ code }),
});
},
/**
* Logout user
*/
logout: async () => {
return fetchAPI('/auth/logout', { method: 'POST' });
},
/**
* Get current user
*/
getCurrentUser: async () => {
return fetchAPI('/auth/me', { method: 'GET' });
},
};
/**
* Projects API
*/
export const projectsAPI = {
/**
* Get all projects
*/
getProjects: async () => {
return fetchAPI('/projects', { method: 'GET' });
},
/**
* Get project by ID
*/
getProject: async (id: string) => {
return fetchAPI(`/projects/${id}`, { method: 'GET' });
},
/**
* Create new project
*/
createProject: async (data: any) => {
return fetchAPI('/projects', {
method: 'POST',
body: JSON.stringify(data),
});
},
/**
* Update project
*/
updateProject: async (id: string, data: any) => {
return fetchAPI(`/projects/${id}`, {
method: 'PATCH',
body: JSON.stringify(data),
});
},
/**
* Delete project
*/
deleteProject: async (id: string) => {
return fetchAPI(`/projects/${id}`, { method: 'DELETE' });
},
};
/**
* Persons API
*/
export const personsAPI = {
/**
* Get all persons for a project
*/
getPersons: async (projectId: string) => {
return fetchAPI(`/projects/${projectId}/persons`, { method: 'GET' });
},
/**
* Get person by ID
*/
getPerson: async (id: string) => {
return fetchAPI(`/persons/${id}`, { method: 'GET' });
},
/**
* Create new person
*/
createPerson: async (projectId: string, data: any) => {
return fetchAPI(`/projects/${projectId}/persons`, {
method: 'POST',
body: JSON.stringify(data),
});
},
/**
* Update person
*/
updatePerson: async (id: string, data: any) => {
return fetchAPI(`/persons/${id}`, {
method: 'PATCH',
body: JSON.stringify(data),
});
},
/**
* Delete person
*/
deletePerson: async (id: string) => {
return fetchAPI(`/persons/${id}`, { method: 'DELETE' });
},
};
/**
* Tags API
*/
export const tagsAPI = {
/**
* Get all tags
*/
getTags: async () => {
return fetchAPI('/tags', { method: 'GET' });
},
/**
* Get tag by ID
*/
getTag: async (id: string) => {
return fetchAPI(`/tags/${id}`, { method: 'GET' });
},
/**
* Create new tag
*/
createTag: async (data: any) => {
return fetchAPI('/tags', {
method: 'POST',
body: JSON.stringify(data),
});
},
/**
* Update tag
*/
updateTag: async (id: string, data: any) => {
return fetchAPI(`/tags/${id}`, {
method: 'PATCH',
body: JSON.stringify(data),
});
},
/**
* Delete tag
*/
deleteTag: async (id: string) => {
return fetchAPI(`/tags/${id}`, { method: 'DELETE' });
},
};
/**
* Groups API
*/
export const groupsAPI = {
/**
* Get all groups for a project
*/
getGroups: async (projectId: string) => {
return fetchAPI(`/projects/${projectId}/groups`, { method: 'GET' });
},
/**
* Get group by ID
*/
getGroup: async (id: string) => {
return fetchAPI(`/groups/${id}`, { method: 'GET' });
},
/**
* Create new group
*/
createGroup: async (projectId: string, data: any) => {
return fetchAPI(`/projects/${projectId}/groups`, {
method: 'POST',
body: JSON.stringify(data),
});
},
/**
* Update group
*/
updateGroup: async (id: string, data: any) => {
return fetchAPI(`/groups/${id}`, {
method: 'PATCH',
body: JSON.stringify(data),
});
},
/**
* Delete group
*/
deleteGroup: async (id: string) => {
return fetchAPI(`/groups/${id}`, { method: 'DELETE' });
},
/**
* Add person to group
*/
addPersonToGroup: async (groupId: string, personId: string) => {
return fetchAPI(`/groups/${groupId}/persons/${personId}`, {
method: 'POST',
});
},
/**
* Remove person from group
*/
removePersonFromGroup: async (groupId: string, personId: string) => {
return fetchAPI(`/groups/${groupId}/persons/${personId}`, {
method: 'DELETE',
});
},
};
export default {
auth: authAPI,
projects: projectsAPI,
persons: personsAPI,
tags: tagsAPI,
groups: groupsAPI,
};

View File

@ -0,0 +1,145 @@
"use client";
import { createContext, useContext, useEffect, useState, ReactNode } from "react";
import { useRouter } from "next/navigation";
import api from "./api";
// Define the User type
interface User {
id: string;
name: string;
avatar?: string;
role: string;
}
// Define the AuthContext type
interface AuthContextType {
user: User | null;
isLoading: boolean;
isAuthenticated: boolean;
login: (code: string) => Promise<void>;
logout: () => Promise<void>;
checkAuth: () => Promise<boolean>;
}
// Create the AuthContext
const AuthContext = createContext<AuthContextType | undefined>(undefined);
// Create a provider component
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const router = useRouter();
// Check if the user is authenticated on mount
useEffect(() => {
const initAuth = async () => {
setIsLoading(true);
try {
await checkAuth();
} catch (error) {
console.error("Auth initialization error:", error);
} finally {
setIsLoading(false);
}
};
initAuth();
}, []);
// Check if the user is authenticated
const checkAuth = async (): Promise<boolean> => {
try {
// Try to get the current user from the API
const userData = await api.auth.getCurrentUser();
if (userData) {
setUser(userData);
// Update localStorage with user data
localStorage.setItem('user_role', userData.role);
localStorage.setItem('user_name', userData.name);
if (userData.avatar) {
localStorage.setItem('user_avatar', userData.avatar);
}
return true;
}
return false;
} catch (error) {
console.error("Auth check error:", error);
setUser(null);
return false;
}
};
// Login function
const login = async (code: string): Promise<void> => {
setIsLoading(true);
try {
const data = await api.auth.githubCallback(code);
if (data.user) {
setUser(data.user);
// Store user info in localStorage
localStorage.setItem('auth_token', data.accessToken);
localStorage.setItem('user_role', data.user.role);
localStorage.setItem('user_name', data.user.name);
if (data.user.avatar) {
localStorage.setItem('user_avatar', data.user.avatar);
}
}
} catch (error) {
console.error("Login error:", error);
throw error;
} finally {
setIsLoading(false);
}
};
// Logout function
const logout = async (): Promise<void> => {
setIsLoading(true);
try {
await api.auth.logout();
// Clear user state and localStorage
setUser(null);
localStorage.removeItem('auth_token');
localStorage.removeItem('user_role');
localStorage.removeItem('user_name');
localStorage.removeItem('user_avatar');
// Redirect to login page
router.push('/auth/login');
} catch (error) {
console.error("Logout error:", error);
throw error;
} finally {
setIsLoading(false);
}
};
// Create the context value
const value = {
user,
isLoading,
isAuthenticated: !!user,
login,
logout,
checkAuth,
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
// Create a hook to use the AuthContext
export function useAuth() {
const context = useContext(AuthContext);
if (context === undefined) {
throw new Error("useAuth must be used within an AuthProvider");
}
return context;
}

60
frontend/middleware.ts Normal file
View File

@ -0,0 +1,60 @@
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
// Define public routes that don't require authentication
const publicRoutes = [
'/',
'/auth/login',
'/auth/callback',
];
// Define routes that require admin role
const adminRoutes = [
'/admin',
];
export function middleware(request: NextRequest) {
const { pathname } = request.nextUrl;
// Allow access to public routes without authentication
if (publicRoutes.some(route => pathname === route || pathname.startsWith(`${route}/`))) {
return NextResponse.next();
}
// Get the auth token from cookies
const token = request.cookies.get('auth_token')?.value;
const userRole = request.cookies.get('user_role')?.value;
// If no token, redirect to login
if (!token) {
// Store the original URL to redirect back after login
const url = new URL('/auth/login', request.url);
url.searchParams.set('callbackUrl', pathname);
return NextResponse.redirect(url);
}
// Check if the route requires admin role
if (adminRoutes.some(route => pathname === route || pathname.startsWith(`${route}/`))) {
// If not admin role, redirect to dashboard
if (userRole !== 'ADMIN') {
return NextResponse.redirect(new URL('/dashboard', request.url));
}
}
return NextResponse.next();
}
// Configure the middleware to run on all routes except static files and api routes
export const config = {
matcher: [
/*
* Match all request paths except for:
* 1. /api routes
* 2. /_next (Next.js internals)
* 3. /_static (static files)
* 4. /_vercel (Vercel internals)
* 5. /favicon.ico, /robots.txt, /sitemap.xml (common static files)
*/
'/((?!api|_next|_static|_vercel|favicon.ico|robots.txt|sitemap.xml).*)',
],
};

View File

@ -52,6 +52,7 @@
"react-resizable-panels": "^3.0.2",
"recharts": "^2.15.3",
"sonner": "^2.0.3",
"swr": "^2.3.3",
"tailwind-merge": "^3.3.0",
"vaul": "^1.1.2",
"zod": "^3.24.4"

18
pnpm-lock.yaml generated
View File

@ -290,6 +290,9 @@ importers:
sonner:
specifier: ^2.0.3
version: 2.0.3(react-dom@19.1.0)(react@19.1.0)
swr:
specifier: ^2.3.3
version: 2.3.3(react@19.1.0)
tailwind-merge:
specifier: ^3.3.0
version: 3.3.0
@ -5857,6 +5860,11 @@ packages:
resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
engines: {node: '>= 0.8'}
/dequal@2.0.3:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'}
dev: false
/detect-libc@2.0.4:
resolution: {integrity: sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==}
engines: {node: '>=8'}
@ -9636,6 +9644,16 @@ packages:
engines: {node: '>= 0.4'}
dev: true
/swr@2.3.3(react@19.1.0):
resolution: {integrity: sha512-dshNvs3ExOqtZ6kJBaAsabhPdHyeY4P2cKwRCniDVifBMoG/SVI7tfLWqPXriVspf2Rg4tPzXJTnwaihIeFw2A==}
peerDependencies:
react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
dependencies:
dequal: 2.0.3
react: 19.1.0
use-sync-external-store: 1.5.0(react@19.1.0)
dev: false
/symbol-observable@4.0.0:
resolution: {integrity: sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==}
engines: {node: '>=0.10'}