feat: add dashboard, projects, and persons pages with reusable components
Implemented the following: - `DashboardPage`: displays an overview of stats, recent projects, and tabs for future analytics/reports. - `ProjectsPage` and `PersonsPage`: include searchable tables, actions, and mobile-friendly card views. - Integrated reusable components like `AuthLoading`, `DropdownMenu`, `Table`, and `Card`.
This commit is contained in:
parent
753669c622
commit
cab80e6aef
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`}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user