Remaster avec skill supabase only et grillme
This commit is contained in:
+42
-27
@@ -1,21 +1,30 @@
|
||||
import { lazy, Suspense } from 'react'
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom'
|
||||
import { AuthProvider } from '@/context/AuthContext'
|
||||
import { ProtectedRoute, AdminRoute } from '@/components/ProtectedRoute'
|
||||
import { ErrorBoundary } from '@/components/ErrorBoundary'
|
||||
import { Layout } from '@/components/Layout'
|
||||
import { Toaster } from 'sonner'
|
||||
import { Login } from '@/pages/Login'
|
||||
import { Register } from '@/pages/Register'
|
||||
import { AcceptInvite } from '@/pages/AcceptInvite'
|
||||
import { Dashboard } from '@/pages/Dashboard'
|
||||
import { Members } from '@/pages/Members'
|
||||
import { Skills } from '@/pages/Skills'
|
||||
import { SkillMatrix } from '@/pages/SkillMatrix'
|
||||
import { History } from '@/pages/History'
|
||||
import { Profile } from '@/pages/Profile'
|
||||
|
||||
const Members = lazy(() => import('@/pages/Members').then(m => ({ default: m.Members })))
|
||||
const Skills = lazy(() => import('@/pages/Skills').then(m => ({ default: m.Skills })))
|
||||
|
||||
function AppLayout({ children }) {
|
||||
return <Layout>{children}</Layout>
|
||||
}
|
||||
|
||||
function SuspenseWrapper({ children }) {
|
||||
return (
|
||||
<Layout>{children}</Layout>
|
||||
<Suspense fallback={<div className="flex items-center justify-center min-h-[60vh] text-gray-400">Chargement...</div>}>
|
||||
{children}
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -23,32 +32,38 @@ export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<Toaster />
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/accept-invite" element={<AcceptInvite />} />
|
||||
<ErrorBoundary>
|
||||
<Toaster />
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/accept-invite" element={<AcceptInvite />} />
|
||||
|
||||
<Route path="/" element={
|
||||
<ProtectedRoute><AppLayout><Dashboard /></AppLayout></ProtectedRoute>
|
||||
} />
|
||||
<Route path="/matrix" element={
|
||||
<ProtectedRoute><AppLayout><SkillMatrix /></AppLayout></ProtectedRoute>
|
||||
} />
|
||||
<Route path="/history" element={
|
||||
<ProtectedRoute><AppLayout><History /></AppLayout></ProtectedRoute>
|
||||
} />
|
||||
<Route path="/profile" element={
|
||||
<ProtectedRoute><AppLayout><Profile /></AppLayout></ProtectedRoute>
|
||||
} />
|
||||
<Route path="/" element={
|
||||
<ProtectedRoute><AppLayout><Dashboard /></AppLayout></ProtectedRoute>
|
||||
} />
|
||||
<Route path="/matrix" element={
|
||||
<ProtectedRoute><AppLayout><SkillMatrix /></AppLayout></ProtectedRoute>
|
||||
} />
|
||||
<Route path="/history" element={
|
||||
<ProtectedRoute><AppLayout><History /></AppLayout></ProtectedRoute>
|
||||
} />
|
||||
<Route path="/profile" element={
|
||||
<ProtectedRoute><AppLayout><Profile /></AppLayout></ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route path="/members" element={
|
||||
<ProtectedRoute><AdminRoute><AppLayout><Members /></AppLayout></AdminRoute></ProtectedRoute>
|
||||
} />
|
||||
<Route path="/skills" element={
|
||||
<ProtectedRoute><AdminRoute><AppLayout><Skills /></AppLayout></AdminRoute></ProtectedRoute>
|
||||
} />
|
||||
</Routes>
|
||||
<Route path="/members" element={
|
||||
<ProtectedRoute><AdminRoute><AppLayout>
|
||||
<SuspenseWrapper><Members /></SuspenseWrapper>
|
||||
</AppLayout></AdminRoute></ProtectedRoute>
|
||||
} />
|
||||
<Route path="/skills" element={
|
||||
<ProtectedRoute><AdminRoute><AppLayout>
|
||||
<SuspenseWrapper><Skills /></SuspenseWrapper>
|
||||
</AppLayout></AdminRoute></ProtectedRoute>
|
||||
} />
|
||||
</Routes>
|
||||
</ErrorBoundary>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
)
|
||||
|
||||
+83
-40
@@ -1,3 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '@/context/AuthContext'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -5,75 +6,117 @@ import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
||||
import {
|
||||
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
LayoutDashboard, BookOpen, Users, Table2, History, Menu, X, Sun, Moon, LogOut, User,
|
||||
} from 'lucide-react'
|
||||
import { useTheme } from 'next-themes'
|
||||
|
||||
const navItems = [
|
||||
{ to: '/', label: 'Tableau de bord', icon: '📊' },
|
||||
{ to: '/skills', label: 'Compétences', icon: '📚', admin: true },
|
||||
{ to: '/members', label: 'Membres', icon: '👥', admin: true },
|
||||
{ to: '/matrix', label: 'Matrice', icon: '📋' },
|
||||
{ to: '/history', label: 'Historique', icon: '📜' },
|
||||
{ to: '/', label: 'Tableau de bord', icon: LayoutDashboard },
|
||||
{ to: '/skills', label: 'Compétences', icon: BookOpen, admin: true },
|
||||
{ to: '/members', label: 'Membres', icon: Users, admin: true },
|
||||
{ to: '/matrix', label: 'Matrice', icon: Table2 },
|
||||
{ to: '/history', label: 'Historique', icon: History },
|
||||
]
|
||||
|
||||
export function Layout({ children }) {
|
||||
const { profile, signOut } = useAuth()
|
||||
const { theme, setTheme } = useTheme()
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||
|
||||
async function handleSignOut() {
|
||||
await signOut()
|
||||
navigate('/login')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex">
|
||||
<aside className="w-64 bg-gray-900 text-white flex flex-col">
|
||||
<div className="p-4 border-b border-gray-700">
|
||||
const sidebar = (
|
||||
<aside className={`w-64 bg-gray-900 text-white flex flex-col shrink-0 ${sidebarOpen ? 'fixed inset-0 z-50' : 'hidden lg:flex'}`}>
|
||||
<div className="p-4 border-b border-gray-700 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-lg font-bold">Compétences</h1>
|
||||
<p className="text-xs text-gray-400">Équipe SysAdmin</p>
|
||||
</div>
|
||||
<nav className="flex-1 p-2 space-y-1">
|
||||
{navItems
|
||||
.filter((item) => !item.admin || profile?.role === 'admin')
|
||||
.map((item) => (
|
||||
{sidebarOpen && (
|
||||
<button className="lg:hidden text-gray-400 hover:text-white" onClick={() => setSidebarOpen(false)}>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<nav className="flex-1 p-2 space-y-1">
|
||||
{navItems
|
||||
.filter((item) => !item.admin || profile?.role === 'admin')
|
||||
.map((item) => {
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<Link
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className={`flex items-center gap-3 px-3 py-2 rounded-md text-sm transition-colors ${
|
||||
location.pathname === item.to
|
||||
? 'bg-gray-700 text-white'
|
||||
: 'text-gray-300 hover:bg-gray-800 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<span>{item.icon}</span>
|
||||
<Icon className="h-4 w-4" />
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
<div className="p-4 border-t border-gray-700">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="w-full flex items-center gap-2 text-gray-300 hover:text-white">
|
||||
<Avatar className="h-6 w-6">
|
||||
<AvatarFallback className="text-xs">
|
||||
{profile?.full_name?.charAt(0)?.toUpperCase() || '?'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-sm truncate">{profile?.full_name || profile?.email}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem onClick={() => navigate('/profile')}>
|
||||
Mon profil
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleSignOut}>
|
||||
Déconnexion
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
<div className="p-4 border-t border-gray-700 space-y-2">
|
||||
<button
|
||||
className="flex items-center gap-2 text-sm text-gray-400 hover:text-white w-full"
|
||||
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
|
||||
>
|
||||
{theme === 'dark' ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||
{theme === 'dark' ? 'Mode clair' : 'Mode sombre'}
|
||||
</button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="w-full flex items-center gap-2 text-gray-300 hover:text-white">
|
||||
<Avatar className="h-6 w-6">
|
||||
<AvatarFallback className="text-xs">
|
||||
{profile?.full_name?.charAt(0)?.toUpperCase() || '?'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-sm truncate">{profile?.full_name || profile?.email}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem onClick={() => navigate('/profile')}>
|
||||
<User className="h-4 w-4 mr-2" />
|
||||
Mon profil
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleSignOut}>
|
||||
<LogOut className="h-4 w-4 mr-2" />
|
||||
Déconnexion
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex bg-gray-50 dark:bg-gray-950 dark:text-gray-100">
|
||||
{sidebarOpen && (
|
||||
<div className="fixed inset-0 bg-black/50 z-40 lg:hidden" onClick={() => setSidebarOpen(false)} />
|
||||
)}
|
||||
{sidebar}
|
||||
<main className="flex-1 flex flex-col min-w-0">
|
||||
<div className="lg:hidden flex items-center justify-between p-4 border-b bg-white dark:bg-gray-900 dark:border-gray-800">
|
||||
<button onClick={() => setSidebarOpen(true)} className="text-gray-700 dark:text-gray-300">
|
||||
<Menu className="h-5 w-5" />
|
||||
</button>
|
||||
<span className="font-bold text-sm">Compétences</span>
|
||||
<div className="w-5" />
|
||||
</div>
|
||||
<div className="p-4 md:p-8 overflow-auto">
|
||||
{children}
|
||||
</div>
|
||||
</aside>
|
||||
<main className="flex-1 bg-gray-50 p-8 overflow-auto">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
const levelConfig = {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import { createContext, useContext, useEffect, useState } from 'react'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
|
||||
|
||||
+4
-1
@@ -1,10 +1,13 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { ThemeProvider } from 'next-themes'
|
||||
import './index.css'
|
||||
import App from './App.jsx'
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
@@ -16,29 +16,36 @@ export function AcceptInvite() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [valid, setValid] = useState(false)
|
||||
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
async function checkToken() {
|
||||
const { data, error } = await supabase
|
||||
const { data } = await supabase
|
||||
.from('invitations')
|
||||
.select('*')
|
||||
.eq('token', token)
|
||||
.eq('accepted', false)
|
||||
.gte('expires_at', new Date().toISOString())
|
||||
.single()
|
||||
if (data && !error) {
|
||||
setEmail(data.email)
|
||||
setValid(true)
|
||||
if (!cancelled) {
|
||||
if (data) {
|
||||
setEmail(data.email)
|
||||
setValid(true)
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
if (token) checkToken()
|
||||
else setLoading(false)
|
||||
return () => { cancelled = true }
|
||||
}, [token])
|
||||
/* eslint-enable react-hooks/set-state-in-effect */
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
|
||||
const { data, error } = await supabase.auth.signUp({
|
||||
const { error } = await supabase.auth.signUp({
|
||||
email,
|
||||
password,
|
||||
options: { data: { full_name: name } },
|
||||
@@ -50,7 +57,6 @@ export function AcceptInvite() {
|
||||
return
|
||||
}
|
||||
|
||||
// Marquer l'invitation comme acceptée
|
||||
await supabase.from('invitations').update({ accepted: true }).eq('token', token)
|
||||
|
||||
toast.success('Compte créé ! Vous pouvez vous connecter.')
|
||||
@@ -69,11 +75,11 @@ export function AcceptInvite() {
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100">
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-950">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl text-center">Accepter l'invitation</CardTitle>
|
||||
<p className="text-sm text-gray-500 text-center mt-1">{email}</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 text-center mt-1">{email}</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
|
||||
@@ -29,7 +29,6 @@ export function Dashboard() {
|
||||
})
|
||||
setRecentChanges(history.data || [])
|
||||
|
||||
// Compétences les mieux notées (moyenne)
|
||||
const { data: levels } = await supabase
|
||||
.from('skill_levels')
|
||||
.select('skill_id, level, skill:skill_id(name)')
|
||||
@@ -54,7 +53,7 @@ export function Dashboard() {
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold">Tableau de bord</h1>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-lg">Membres</CardTitle></CardHeader>
|
||||
<CardContent><p className="text-3xl font-bold">{stats.members}</p></CardContent>
|
||||
@@ -69,12 +68,12 @@ export function Dashboard() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-lg">Compétences les mieux notées</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
{topSkills.length === 0 ? (
|
||||
<p className="text-gray-500">Aucune évaluation pour le moment</p>
|
||||
<p className="text-gray-500 dark:text-gray-400">Aucune évaluation pour le moment</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{topSkills.map((s) => (
|
||||
@@ -92,11 +91,11 @@ export function Dashboard() {
|
||||
<CardHeader><CardTitle className="text-lg">Dernières évolutions</CardTitle></CardHeader>
|
||||
<CardContent className="max-h-80 overflow-auto">
|
||||
{recentChanges.length === 0 ? (
|
||||
<p className="text-gray-500">Aucun changement récent</p>
|
||||
<p className="text-gray-500 dark:text-gray-400">Aucun changement récent</p>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{recentChanges.map((c) => (
|
||||
<li key={c.id} className="text-sm border-b pb-2 last:border-0">
|
||||
<li key={c.id} className="text-sm border-b dark:border-gray-800 pb-2 last:border-0">
|
||||
<span className="font-medium">{c.member?.full_name}</span>
|
||||
{' '}a mis à jour{' '}
|
||||
<span className="font-medium">{c.skill?.name}</span>
|
||||
|
||||
+34
-47
@@ -1,44 +1,15 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { useMembers } from '@/hooks/useMembers'
|
||||
import { useSkills } from '@/hooks/useSkills'
|
||||
import { useHistory } from '@/hooks/useHistory'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { SkillLevelBadge } from '@/components/SkillLevelBadge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
|
||||
export function History() {
|
||||
const [history, setHistory] = useState([])
|
||||
const [members, setMembers] = useState([])
|
||||
const [skills, setSkills] = useState([])
|
||||
const [filterMember, setFilterMember] = useState('all')
|
||||
const [filterSkill, setFilterSkill] = useState('all')
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
const [memRes, skillRes] = await Promise.all([
|
||||
supabase.from('members').select('*').order('full_name'),
|
||||
supabase.from('skills').select('*').order('name'),
|
||||
])
|
||||
if (memRes.data) setMembers(memRes.data)
|
||||
if (skillRes.data) setSkills(skillRes.data)
|
||||
}
|
||||
load()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
async function loadHistory() {
|
||||
let query = supabase
|
||||
.from('skill_history')
|
||||
.select('*, member:member_id(full_name), skill:skill_id(name), changer:changed_by(full_name)')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(100)
|
||||
|
||||
if (filterMember !== 'all') query = query.eq('member_id', filterMember)
|
||||
if (filterSkill !== 'all') query = query.eq('skill_id', filterSkill)
|
||||
|
||||
const { data } = await query
|
||||
if (data) setHistory(data)
|
||||
}
|
||||
loadHistory()
|
||||
}, [filterMember, filterSkill])
|
||||
const { members } = useMembers()
|
||||
const { skills } = useSkills()
|
||||
const { history, loading, count, page, totalPages, filters, setFilter, nextPage, prevPage } = useHistory()
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -46,9 +17,9 @@ export function History() {
|
||||
|
||||
<div className="flex gap-4">
|
||||
<select
|
||||
className="border rounded px-2 py-1 text-sm"
|
||||
value={filterMember}
|
||||
onChange={(e) => setFilterMember(e.target.value)}
|
||||
className="border rounded px-2 py-1 text-sm dark:bg-gray-800 dark:border-gray-700"
|
||||
value={filters.memberId}
|
||||
onChange={(e) => setFilter('memberId', e.target.value)}
|
||||
>
|
||||
<option value="all">Tous les membres</option>
|
||||
{members.map((m) => (
|
||||
@@ -56,9 +27,9 @@ export function History() {
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="border rounded px-2 py-1 text-sm"
|
||||
value={filterSkill}
|
||||
onChange={(e) => setFilterSkill(e.target.value)}
|
||||
className="border rounded px-2 py-1 text-sm dark:bg-gray-800 dark:border-gray-700"
|
||||
value={filters.skillId}
|
||||
onChange={(e) => setFilter('skillId', e.target.value)}
|
||||
>
|
||||
<option value="all">Toutes les compétences</option>
|
||||
{skills.map((s) => (
|
||||
@@ -69,10 +40,12 @@ export function History() {
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{history.length === 0 ? (
|
||||
{loading ? (
|
||||
<p className="p-6 text-gray-400">Chargement...</p>
|
||||
) : history.length === 0 ? (
|
||||
<p className="p-6 text-gray-400">Aucun historique</p>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
<ul className="divide-y dark:divide-gray-800">
|
||||
{history.map((h) => (
|
||||
<li key={h.id} className="p-4 flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
@@ -80,7 +53,7 @@ export function History() {
|
||||
<span className="font-medium">{h.member?.full_name}</span>
|
||||
{' → '}<span className="font-medium">{h.skill?.name}</span>
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Par {h.changer?.full_name} — {new Date(h.created_at).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
@@ -95,6 +68,20 @@ export function History() {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between text-sm text-gray-500 dark:text-gray-400">
|
||||
<span>{count} entrées — Page {page + 1} / {totalPages}</span>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={prevPage} disabled={page === 0}>
|
||||
<ChevronLeft className="h-4 w-4" /> Précédent
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={nextPage} disabled={page >= totalPages - 1}>
|
||||
Suivant <ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+24
-2
@@ -4,10 +4,11 @@ import { useAuth } from '@/context/AuthContext'
|
||||
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, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||
import { InviteUserModal } from '@/components/InviteUserModal'
|
||||
import { Download } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export function Members() {
|
||||
@@ -37,11 +38,32 @@ export function Members() {
|
||||
toast.success('Membre supprimé')
|
||||
}
|
||||
|
||||
function exportCSV() {
|
||||
const header = 'Nom,Email,Rôle,Inscrit le\n'
|
||||
const rows = members.map((m) =>
|
||||
`"${m.full_name || ''}","${m.email}","${m.role}","${new Date(m.created_at).toLocaleDateString()}"`
|
||||
).join('\n')
|
||||
const blob = new Blob([header + rows], { type: 'text/csv;charset=utf-8;' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'membres.csv'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
toast.success('Fichier CSV téléchargé')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Membres</h1>
|
||||
<InviteUserModal />
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={exportCSV}>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
CSV
|
||||
</Button>
|
||||
<InviteUserModal />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
|
||||
@@ -27,15 +27,15 @@ export function Profile() {
|
||||
<CardHeader><CardTitle>Informations</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm text-gray-600">Email</label>
|
||||
<label className="text-sm text-gray-600 dark:text-gray-400">Email</label>
|
||||
<p className="font-medium">{profile?.email}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-gray-600">Rôle</label>
|
||||
<label className="text-sm text-gray-600 dark:text-gray-400">Rôle</label>
|
||||
<p className="font-medium capitalize">{profile?.role}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-gray-600">Nom complet</label>
|
||||
<label className="text-sm text-gray-600 dark:text-gray-400">Nom complet</label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</div>
|
||||
<Button onClick={handleSave}>Enregistrer</Button>
|
||||
|
||||
+45
-159
@@ -1,42 +1,30 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import { useState } from 'react'
|
||||
import { useAuth } from '@/context/AuthContext'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { SkillLevelBadge, SkillLevelSelect } from '@/components/SkillLevelBadge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useCategories } from '@/hooks/useCategories'
|
||||
import { useSkills } from '@/hooks/useSkills'
|
||||
import { useMembers } from '@/hooks/useMembers'
|
||||
import { useSkillLevels } from '@/hooks/useSkillLevels'
|
||||
import { SkillMatrixFilters } from '@/components/matrix/SkillMatrixFilters'
|
||||
import { SkillMatrixTable } from '@/components/matrix/SkillMatrixTable'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export function SkillMatrix() {
|
||||
const { profile } = useAuth()
|
||||
const isAdmin = profile?.role === 'admin'
|
||||
const [categories, setCategories] = useState([])
|
||||
const [skills, setSkills] = useState([])
|
||||
const [members, setMembers] = useState([])
|
||||
const [levels, setLevels] = useState({})
|
||||
const { categories } = useCategories()
|
||||
const { skills } = useSkills()
|
||||
const { members } = useMembers()
|
||||
const { levels, updateLevel } = useSkillLevels()
|
||||
|
||||
const [filterCat, setFilterCat] = useState('all')
|
||||
const [filterMember, setFilterMember] = useState('all')
|
||||
const [filterMinLevel, setFilterMinLevel] = useState(0)
|
||||
const [editing, setEditing] = useState(null)
|
||||
|
||||
useEffect(() => { load() }, [])
|
||||
|
||||
async function load() {
|
||||
const [catRes, skillRes, memberRes, levelRes] = await Promise.all([
|
||||
supabase.from('categories').select('*').order('name'),
|
||||
supabase.from('skills').select('*').order('name'),
|
||||
supabase.from('members').select('*').order('full_name'),
|
||||
supabase.from('skill_levels').select('*'),
|
||||
])
|
||||
if (catRes.data) setCategories(catRes.data)
|
||||
if (skillRes.data) setSkills(skillRes.data)
|
||||
if (memberRes.data) setMembers(memberRes.data)
|
||||
if (levelRes.data) {
|
||||
const map = {}
|
||||
levelRes.data.forEach((l) => {
|
||||
map[`${l.member_id}-${l.skill_id}`] = l
|
||||
})
|
||||
setLevels(map)
|
||||
}
|
||||
function onFilterChange(key, value) {
|
||||
if (key === 'cat') setFilterCat(value)
|
||||
if (key === 'member') setFilterMember(value)
|
||||
if (key === 'minLevel') setFilterMinLevel(value)
|
||||
}
|
||||
|
||||
const filteredSkills = filterCat === 'all'
|
||||
@@ -47,37 +35,17 @@ export function SkillMatrix() {
|
||||
? members
|
||||
: members.filter((m) => m.id === filterMember)
|
||||
|
||||
const filteredByLevel = filteredMembers.filter((m) => {
|
||||
if (filterMinLevel === 0) return true
|
||||
return filteredSkills.some((s) => {
|
||||
const key = `${m.id}-${s.id}`
|
||||
return (levels[key]?.level || 0) >= filterMinLevel
|
||||
})
|
||||
})
|
||||
const visibleMembers = filterMinLevel === 0
|
||||
? filteredMembers
|
||||
: filteredMembers.filter((m) =>
|
||||
filteredSkills.some((s) => {
|
||||
const key = `${m.id}-${s.id}`
|
||||
return (levels[key]?.level || 0) >= filterMinLevel
|
||||
})
|
||||
)
|
||||
|
||||
async function updateLevel(memberId, skillId, newLevel) {
|
||||
const key = `${memberId}-${skillId}`
|
||||
const existing = levels[key]
|
||||
const oldLevel = existing?.level
|
||||
|
||||
if (existing) {
|
||||
await supabase.from('skill_levels').update({ level: newLevel }).eq('id', existing.id)
|
||||
} else {
|
||||
await supabase.from('skill_levels').insert({ member_id: memberId, skill_id: skillId, level: newLevel })
|
||||
}
|
||||
|
||||
// Historique
|
||||
if (oldLevel !== newLevel) {
|
||||
await supabase.from('skill_history').insert({
|
||||
member_id: memberId,
|
||||
skill_id: skillId,
|
||||
old_level: oldLevel || null,
|
||||
new_level: newLevel,
|
||||
changed_by: profile.id,
|
||||
})
|
||||
}
|
||||
|
||||
load()
|
||||
async function handleUpdate(memberId, skillId, newLevel) {
|
||||
await updateLevel(memberId, skillId, newLevel, profile.id)
|
||||
setEditing(null)
|
||||
toast.success('Niveau mis à jour')
|
||||
}
|
||||
@@ -86,109 +54,27 @@ export function SkillMatrix() {
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold">Matrice des compétences</h1>
|
||||
|
||||
<div className="flex gap-4 flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm text-gray-600">Catégorie :</label>
|
||||
<select
|
||||
className="border rounded px-2 py-1 text-sm"
|
||||
value={filterCat}
|
||||
onChange={(e) => setFilterCat(e.target.value)}
|
||||
>
|
||||
<option value="all">Toutes</option>
|
||||
{categories.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm text-gray-600">Membre :</label>
|
||||
<select
|
||||
className="border rounded px-2 py-1 text-sm"
|
||||
value={filterMember}
|
||||
onChange={(e) => setFilterMember(e.target.value)}
|
||||
>
|
||||
<option value="all">Tous</option>
|
||||
{members.map((m) => (
|
||||
<option key={m.id} value={m.id}>{m.full_name || m.email}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm text-gray-600">Niveau min. :</label>
|
||||
<select
|
||||
className="border rounded px-2 py-1 text-sm"
|
||||
value={filterMinLevel}
|
||||
onChange={(e) => setFilterMinLevel(Number(e.target.value))}
|
||||
>
|
||||
<option value={0}>Aucun</option>
|
||||
{[1, 2, 3, 4].map((l) => <option key={l} value={l}>{l}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<SkillMatrixFilters
|
||||
categories={categories}
|
||||
members={members}
|
||||
filterCat={filterCat}
|
||||
filterMember={filterMember}
|
||||
filterMinLevel={filterMinLevel}
|
||||
onFilterChange={onFilterChange}
|
||||
/>
|
||||
|
||||
<div className="overflow-auto">
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="text-left p-2 bg-gray-100 border sticky left-0 z-10 min-w-[180px]">Membre</th>
|
||||
{filteredSkills.map((s) => (
|
||||
<th key={s.id} className="p-2 bg-gray-100 border text-sm text-center min-w-[120px]">{s.name}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredByLevel.length === 0 && (
|
||||
<tr><td colSpan={filteredSkills.length + 1} className="p-8 text-center text-gray-400">Aucun résultat</td></tr>
|
||||
)}
|
||||
{filteredByLevel.map((m) => (
|
||||
<tr key={m.id} className="hover:bg-gray-50">
|
||||
<td className="p-2 border font-medium sticky left-0 bg-white">
|
||||
<span className="text-sm">{m.full_name || m.email}</span>
|
||||
</td>
|
||||
{filteredSkills.map((s) => {
|
||||
const key = `${m.id}-${s.id}`
|
||||
const level = levels[key]
|
||||
const isEditing = editing === key
|
||||
return (
|
||||
<td key={s.id} className="p-2 border text-center">
|
||||
{isEditing && isAdmin ? (
|
||||
<SkillLevelSelect
|
||||
value={level?.level || 1}
|
||||
onChange={(v) => updateLevel(m.id, s.id, v)}
|
||||
/>
|
||||
) : (
|
||||
level ? (
|
||||
<SkillLevelBadge
|
||||
level={level.level}
|
||||
onClick={() => isAdmin && setEditing(key)}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="text-gray-300 text-sm cursor-pointer"
|
||||
onClick={() => isAdmin && setEditing(key)}
|
||||
>
|
||||
—
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
{isEditing && isAdmin && (
|
||||
<button
|
||||
className="ml-1 text-xs text-red-500"
|
||||
onClick={() => setEditing(null)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<SkillMatrixTable
|
||||
skills={filteredSkills}
|
||||
members={visibleMembers}
|
||||
levels={levels}
|
||||
isAdmin={isAdmin}
|
||||
editing={editing}
|
||||
onEdit={setEditing}
|
||||
onUpdate={handleUpdate}
|
||||
onCancel={() => setEditing(null)}
|
||||
/>
|
||||
|
||||
<div className="flex gap-4 text-sm text-gray-500">
|
||||
<div className="flex gap-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
<span><span className="inline-block w-3 h-3 rounded-full bg-gray-200 mr-1" /> Débutant</span>
|
||||
<span><span className="inline-block w-3 h-3 rounded-full bg-blue-200 mr-1" /> Intermédiaire</span>
|
||||
<span><span className="inline-block w-3 h-3 rounded-full bg-amber-200 mr-1" /> Avancé</span>
|
||||
|
||||
+36
-57
@@ -1,15 +1,18 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import { useCategories } from '@/hooks/useCategories'
|
||||
import { useSkills } from '@/hooks/useSkills'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||
import { CategoryCard } from '@/components/skills/CategoryCard'
|
||||
import { Search } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export function Skills() {
|
||||
const [categories, setCategories] = useState([])
|
||||
const [skills, setSkills] = useState([])
|
||||
const { categories, refetch: refetchCats } = useCategories()
|
||||
const { skills, refetch: refetchSkills } = useSkills()
|
||||
const [search, setSearch] = useState('')
|
||||
const [newCatName, setNewCatName] = useState('')
|
||||
const [newCatColor, setNewCatColor] = useState('#3b82f6')
|
||||
const [editCat, setEditCat] = useState(null)
|
||||
@@ -17,17 +20,6 @@ export function Skills() {
|
||||
const [catDialogOpen, setCatDialogOpen] = useState(false)
|
||||
const [skillDialogOpen, setSkillDialogOpen] = useState(false)
|
||||
|
||||
useEffect(() => { load() }, [])
|
||||
|
||||
async function load() {
|
||||
const [catRes, skillRes] = await Promise.all([
|
||||
supabase.from('categories').select('*').order('name'),
|
||||
supabase.from('skills').select('*, category:category_id(name)').order('name'),
|
||||
])
|
||||
if (catRes.data) setCategories(catRes.data)
|
||||
if (skillRes.data) setSkills(skillRes.data)
|
||||
}
|
||||
|
||||
async function saveCategory() {
|
||||
if (editCat) {
|
||||
await supabase.from('categories').update({ name: newCatName, color: newCatColor }).eq('id', editCat)
|
||||
@@ -37,28 +29,29 @@ export function Skills() {
|
||||
setCatDialogOpen(false)
|
||||
setEditCat(null)
|
||||
setNewCatName('')
|
||||
load()
|
||||
refetchCats()
|
||||
toast.success('Catégorie enregistrée')
|
||||
}
|
||||
|
||||
async function deleteCategory(id) {
|
||||
const { error } = await supabase.from('categories').delete().eq('id', id)
|
||||
if (error) toast.error(error.message)
|
||||
else { load(); toast.success('Catégorie supprimée') }
|
||||
else { refetchCats(); toast.success('Catégorie supprimée') }
|
||||
}
|
||||
|
||||
async function saveSkill() {
|
||||
await supabase.from('skills').insert({ name: newSkill.name, category_id: newSkill.category_id })
|
||||
setSkillDialogOpen(false)
|
||||
setNewSkill({ name: '', category_id: '' })
|
||||
load()
|
||||
refetchSkills()
|
||||
toast.success('Compétence ajoutée')
|
||||
}
|
||||
|
||||
async function deleteSkill(id) {
|
||||
await supabase.from('skills').delete().eq('id', id)
|
||||
load()
|
||||
toast.success('Compétence supprimée')
|
||||
function openEditCategory(cat) {
|
||||
setEditCat(cat.id)
|
||||
setNewCatName(cat.name)
|
||||
setNewCatColor(cat.color)
|
||||
setCatDialogOpen(true)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -73,7 +66,7 @@ export function Skills() {
|
||||
<div className="space-y-4">
|
||||
<Input placeholder="Nom" value={newSkill.name} onChange={(e) => setNewSkill({ ...newSkill, name: e.target.value })} />
|
||||
<select
|
||||
className="w-full border rounded-md px-3 py-2"
|
||||
className="w-full border rounded-md px-3 py-2 dark:bg-gray-800 dark:border-gray-700"
|
||||
value={newSkill.category_id}
|
||||
onChange={(e) => setNewSkill({ ...newSkill, category_id: e.target.value })}
|
||||
>
|
||||
@@ -99,41 +92,27 @@ export function Skills() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
className="pl-10"
|
||||
placeholder="Rechercher une compétence..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{categories.map((cat) => {
|
||||
const catSkills = skills.filter((s) => s.category_id === cat.id)
|
||||
const catSkills = skills.filter((s) => s.category_id === cat.id && (!search || s.name.toLowerCase().includes(search.toLowerCase())))
|
||||
if (search && catSkills.length === 0) return null
|
||||
return (
|
||||
<Card key={cat.id}>
|
||||
<CardHeader className="flex flex-row items-center justify-between py-3">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded-full" style={{ backgroundColor: cat.color }} />
|
||||
{cat.name}
|
||||
<Badge variant="secondary" className="ml-2">{catSkills.length}</Badge>
|
||||
</CardTitle>
|
||||
<div className="flex gap-1">
|
||||
<Button size="sm" variant="ghost" onClick={() => {
|
||||
setEditCat(cat.id)
|
||||
setNewCatName(cat.name)
|
||||
setNewCatColor(cat.color)
|
||||
setCatDialogOpen(true)
|
||||
}}>✎</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => deleteCategory(cat.id)}>🗑</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{catSkills.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">Aucune compétence dans cette catégorie</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{catSkills.map((s) => (
|
||||
<Badge key={s.id} variant="outline" className="pr-1">
|
||||
{s.name}
|
||||
<button className="ml-1 text-gray-400 hover:text-red-500" onClick={() => deleteSkill(s.id)}>×</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<CategoryCard
|
||||
key={cat.id}
|
||||
category={cat}
|
||||
skills={catSkills}
|
||||
onEdit={openEditCategory}
|
||||
onDelete={deleteCategory}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user