Compare commits
6 Commits
cf292de428
...
bb16aaee40
Author | SHA1 | Date | |
---|---|---|---|
bb16aaee40 | |||
bb62a374c5 | |||
a56e774892 | |||
cd5ad2e1e4 | |||
cab80e6aef | |||
753669c622 |
@ -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;
|
||||
|
@ -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.
|
||||
|
@ -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
|
10
frontend/app/admin/layout.tsx
Normal file
10
frontend/app/admin/layout.tsx
Normal 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
199
frontend/app/admin/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
581
frontend/app/admin/settings/page.tsx
Normal file
581
frontend/app/admin/settings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
319
frontend/app/admin/stats/page.tsx
Normal file
319
frontend/app/admin/stats/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
278
frontend/app/admin/tags/page.tsx
Normal file
278
frontend/app/admin/tags/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
301
frontend/app/admin/users/page.tsx
Normal file
301
frontend/app/admin/users/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
79
frontend/app/auth/callback/page.tsx
Normal file
79
frontend/app/auth/callback/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
74
frontend/app/auth/login/page.tsx
Normal file
74
frontend/app/auth/login/page.tsx
Normal 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'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'utilisation et notre politique de confidentialité.
|
||||
</p>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
36
frontend/app/auth/logout/page.tsx
Normal file
36
frontend/app/auth/logout/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
10
frontend/app/dashboard/layout.tsx
Normal file
10
frontend/app/dashboard/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
176
frontend/app/dashboard/page.tsx
Normal file
176
frontend/app/dashboard/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
@ -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>
|
||||
);
|
||||
|
@ -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>
|
||||
);
|
||||
|
346
frontend/app/persons/[id]/edit/page.tsx
Normal file
346
frontend/app/persons/[id]/edit/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
10
frontend/app/persons/layout.tsx
Normal file
10
frontend/app/persons/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
286
frontend/app/persons/new/page.tsx
Normal file
286
frontend/app/persons/new/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
193
frontend/app/persons/page.tsx
Normal file
193
frontend/app/persons/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
186
frontend/app/projects/[id]/edit/page.tsx
Normal file
186
frontend/app/projects/[id]/edit/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
432
frontend/app/projects/[id]/groups/auto-create/page.tsx
Normal file
432
frontend/app/projects/[id]/groups/auto-create/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
379
frontend/app/projects/[id]/groups/create/page.tsx
Normal file
379
frontend/app/projects/[id]/groups/create/page.tsx
Normal 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 été 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>
|
||||
);
|
||||
}
|
256
frontend/app/projects/[id]/groups/page.tsx
Normal file
256
frontend/app/projects/[id]/groups/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
10
frontend/app/projects/layout.tsx
Normal file
10
frontend/app/projects/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
134
frontend/app/projects/new/page.tsx
Normal file
134
frontend/app/projects/new/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
262
frontend/app/projects/page.tsx
Normal file
262
frontend/app/projects/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
10
frontend/app/settings/layout.tsx
Normal file
10
frontend/app/settings/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
268
frontend/app/settings/page.tsx
Normal file
268
frontend/app/settings/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
277
frontend/app/tags/[id]/edit/page.tsx
Normal file
277
frontend/app/tags/[id]/edit/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
172
frontend/app/tags/demo/page.tsx
Normal file
172
frontend/app/tags/demo/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
10
frontend/app/tags/layout.tsx
Normal file
10
frontend/app/tags/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
215
frontend/app/tags/new/page.tsx
Normal file
215
frontend/app/tags/new/page.tsx
Normal 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
212
frontend/app/tags/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
140
frontend/components/admin-layout.tsx
Normal file
140
frontend/components/admin-layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
24
frontend/components/auth-loading.tsx
Normal file
24
frontend/components/auth-loading.tsx
Normal 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}</>;
|
||||
}
|
168
frontend/components/dashboard-layout.tsx
Normal file
168
frontend/components/dashboard-layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
218
frontend/components/tag-selector.tsx
Normal file
218
frontend/components/tag-selector.tsx
Normal 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>
|
||||
);
|
||||
}
|
9
frontend/components/theme-provider.tsx
Normal file
9
frontend/components/theme-provider.tsx
Normal 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>;
|
||||
}
|
91
frontend/docs/RESPONSIVE_DESIGN.md
Normal file
91
frontend/docs/RESPONSIVE_DESIGN.md
Normal 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
301
frontend/lib/api.ts
Normal 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,
|
||||
};
|
145
frontend/lib/auth-context.tsx
Normal file
145
frontend/lib/auth-context.tsx
Normal 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
60
frontend/middleware.ts
Normal 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).*)',
|
||||
],
|
||||
};
|
@ -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
18
pnpm-lock.yaml
generated
@ -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'}
|
||||
|
Loading…
x
Reference in New Issue
Block a user