Compare commits
7 Commits
qualif
...
2c35fe53b9
| Author | SHA1 | Date | |
|---|---|---|---|
| 2c35fe53b9 | |||
| 42c2ab10d5 | |||
| 1108069b1a | |||
| c990901944 | |||
| f950f3d17a | |||
| 62a701a160 | |||
| 66f27afbac |
@@ -0,0 +1,12 @@
|
|||||||
|
# Supabase Configuration
|
||||||
|
# Copy this file to .env and fill in your actual values
|
||||||
|
|
||||||
|
# Supabase API URL
|
||||||
|
VITE_SUPABASE_URL=http://localhost:8000
|
||||||
|
|
||||||
|
# Supabase anonymous key (public, used by the frontend)
|
||||||
|
VITE_SUPABASE_ANON_KEY=your-anon-key-here
|
||||||
|
|
||||||
|
# Supabase service role key (secret, NEVER exposed to the frontend)
|
||||||
|
# Only used by scripts and edge functions
|
||||||
|
VITE_SUPABASE_SERVICE_ROLE_KEY=your-service-role-key-here
|
||||||
+5
-1
@@ -24,5 +24,9 @@ dist-ssr
|
|||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
# Environment
|
# Environment
|
||||||
#.env
|
.env
|
||||||
.env.local
|
.env.local
|
||||||
|
|
||||||
|
# OpenCode Stuff
|
||||||
|
.agents/*
|
||||||
|
opencode*
|
||||||
|
|||||||
@@ -11,27 +11,51 @@ npm run dev # Vite dev server with HMR
|
|||||||
npm run build # Production build → dist/
|
npm run build # Production build → dist/
|
||||||
npm run lint # ESLint (flat config)
|
npm run lint # ESLint (flat config)
|
||||||
npm run preview # Serve dist/ locally
|
npm run preview # Serve dist/ locally
|
||||||
|
npm run test # Vitest run (hooks tests)
|
||||||
|
npm run test:watch# Vitest watch mode
|
||||||
```
|
```
|
||||||
|
|
||||||
No test runner, no typecheck script.
|
No typecheck script.
|
||||||
|
|
||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
- **Entry**: `src/main.jsx` → `src/App.jsx`
|
- **Entry**: `src/main.jsx` → wrapped in `<ThemeProvider>` (next-themes) → `src/App.jsx`
|
||||||
- **Auth**: `src/context/AuthContext.jsx` — Supabase Auth + `members` table profile (role: admin/member)
|
- **Auth**: `src/context/AuthContext.jsx` — Supabase Auth + `members` table profile (role: admin/member)
|
||||||
- **Routing**: `src/App.jsx` — public routes `/login`, `/register`, `/accept-invite`; protected routes wrapped in `<ProtectedRoute>`; admin-only routes (`/members`, `/skills`) also wrapped in `<AdminRoute>`
|
- **Routing**: `src/App.jsx` — public routes `/login`, `/register`, `/accept-invite`; protected routes wrapped in `<ProtectedRoute>`; admin-only routes (`/members`, `/skills`) also wrapped in `<AdminRoute>` + `<Suspense>` (React.lazy code splitting)
|
||||||
|
- **Error handling**: `<ErrorBoundary>` wraps all protected routes
|
||||||
- **Pages**: `src/pages/` — Dashboard, Members, Skills, SkillMatrix, History, Profile
|
- **Pages**: `src/pages/` — Dashboard, Members, Skills, SkillMatrix, History, Profile
|
||||||
- **UI**: `src/components/ui/` — shadcn components (radix-nova style, JS, no TSX)
|
- **UI**: `src/components/ui/` — shadcn components (radix-nova style, JS, no TSX)
|
||||||
- **Supabase client**: `src/lib/supabase.js` — reads `VITE_SUPABASE_URL` / `VITE_SUPABASE_ANON_KEY` from env
|
- **Supabase client**: `src/lib/supabase.js` — reads `VITE_SUPABASE_URL` / `VITE_SUPABASE_ANON_KEY` from env
|
||||||
- **Path alias**: `@/*` → `src/*` (configured in both `vite.config.js` and `jsconfig.json`)
|
- **Path alias**: `@/*` → `src/*` (configured in both `vite.config.js` and `jsconfig.json`)
|
||||||
|
|
||||||
|
## Data Layer
|
||||||
|
|
||||||
|
### Custom hooks in `src/hooks/`
|
||||||
|
|
||||||
|
| Hook | Returns | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `useMembers()` | `{ members, loading, refetch }` | Full-text search via GIN index |
|
||||||
|
| `useSkills()` | `{ skills, loading, refetch }` | Includes `category:category_id(name)` join |
|
||||||
|
| `useCategories()` | `{ categories, loading, refetch }` | Ordered by name |
|
||||||
|
| `useSkillLevels()` | `{ levels, loading, refetch, updateLevel, getAverageSkillRating }` | `levels` is a `{memberId-skillId → level}` map. Has real-time subscription |
|
||||||
|
| `useHistory()` | `{ history, loading, count, page, totalPages, filters, setFilter, nextPage, prevPage, refetch }` | Paginated (50/page), real-time on INSERT |
|
||||||
|
|
||||||
|
### Data fetching patterns
|
||||||
|
- Hooks call Supabase directly (no additional API layer)
|
||||||
|
- No global state management (hooks + local state only)
|
||||||
|
- Real-time subscriptions via `supabase.channel()` on `skill_levels` and `skill_history`
|
||||||
|
|
||||||
## Supabase
|
## Supabase
|
||||||
|
|
||||||
- DB schema + RLS policies in `supabase/migrations/001_init.sql`
|
- DB schema + RLS policies in `supabase/migrations/001_init.sql`
|
||||||
- Tables: `categories`, `skills`, `members`, `level_descriptions`, `skill_levels`, `skill_history`, `invitations`
|
- Tables: `categories`, `skills`, `members`, `level_descriptions`, `skill_levels`, `skill_history`, `invitations`
|
||||||
- `handle_new_user()` trigger auto-creates a `members` row on auth signup
|
- `handle_new_user()` trigger auto-creates a `members` row on auth signup
|
||||||
- RLS: read-all for most tables, write restricted to admin role
|
- RLS: read-all for most tables, write restricted to admin role
|
||||||
- `.env` points to a local Supabase instance (`192.168.2.220:8000`) — this is committed but `.env` is gitignored; adjust for your environment
|
- UPDATE policies include `WITH CHECK` matching the `USING` clause
|
||||||
|
- `invitations_read_admin` policy filters `expires_at > now()`
|
||||||
|
- Full-text search enabled via GIN indexes on `skills.name` and `members.full_name`
|
||||||
|
- Realtime publication enabled on `skill_levels` and `skill_history`
|
||||||
|
- `.env` points to a local Supabase instance (`vm-docker5.home.arpa:8000`) — this is committed but `.env` is gitignored; adjust for your environment
|
||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
|
|
||||||
@@ -53,3 +77,6 @@ Components land in `src/components/ui/`. Uses Lucide icons.
|
|||||||
- ESLint flat config (`eslint.config.js`) — ignores `dist/`
|
- ESLint flat config (`eslint.config.js`) — ignores `dist/`
|
||||||
- Tailwind v4 via `@tailwindcss/vite` plugin (no `tailwind.config.js`)
|
- Tailwind v4 via `@tailwindcss/vite` plugin (no `tailwind.config.js`)
|
||||||
- French UI labels (application is in French)
|
- French UI labels (application is in French)
|
||||||
|
- Dark mode support via `next-themes` with class strategy
|
||||||
|
- Layout: Lucide icons, responsive sidebar (hamburger on mobile)
|
||||||
|
- CSV exports for Members and Matrix pages
|
||||||
|
|||||||
@@ -18,4 +18,15 @@ export default defineConfig([
|
|||||||
parserOptions: { ecmaFeatures: { jsx: true } },
|
parserOptions: { ecmaFeatures: { jsx: true } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
files: ['src/components/ui/**'],
|
||||||
|
rules: {
|
||||||
|
'no-unused-vars': 'off',
|
||||||
|
'react-refresh/only-export-components': 'off',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['vite.config.js'],
|
||||||
|
rules: { 'no-undef': 'off' },
|
||||||
|
},
|
||||||
])
|
])
|
||||||
|
|||||||
Generated
+786
-4
File diff suppressed because it is too large
Load Diff
+9
-4
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "gestiondescmpetences",
|
"name": "gestiondescompetences",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -7,20 +7,23 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fontsource-variable/geist": "^5.2.9",
|
"@fontsource-variable/geist": "^5.2.9",
|
||||||
"@supabase/supabase-js": "^2.105.4",
|
"@supabase/supabase-js": "^2.105.4",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"d3-force": "^3.0.0",
|
||||||
"lucide-react": "^1.16.0",
|
"lucide-react": "^1.16.0",
|
||||||
"next-themes": "^0.4.6",
|
"next-themes": "^0.4.6",
|
||||||
"pg": "^8.21.0",
|
|
||||||
"radix-ui": "^1.4.3",
|
"radix-ui": "^1.4.3",
|
||||||
"react": "^19.2.6",
|
"react": "^19.2.6",
|
||||||
"react-dom": "^19.2.6",
|
"react-dom": "^19.2.6",
|
||||||
"react-router-dom": "^7.15.1",
|
"react-router-dom": "^7.15.1",
|
||||||
|
"recharts": "^3.8.1",
|
||||||
"shadcn": "^4.7.0",
|
"shadcn": "^4.7.0",
|
||||||
"sonner": "^2.0.7",
|
"sonner": "^2.0.7",
|
||||||
"tailwind-merge": "^3.6.0",
|
"tailwind-merge": "^3.6.0",
|
||||||
@@ -37,7 +40,9 @@
|
|||||||
"eslint-plugin-react-hooks": "^7.1.1",
|
"eslint-plugin-react-hooks": "^7.1.1",
|
||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
"globals": "^17.6.0",
|
"globals": "^17.6.0",
|
||||||
|
"pg": "^8.21.0",
|
||||||
"tailwindcss": "^4.3.0",
|
"tailwindcss": "^4.3.0",
|
||||||
"vite": "^8.0.12"
|
"vite": "^8.0.12",
|
||||||
|
"vitest": "^4.1.7"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+19
-6
@@ -1,11 +1,24 @@
|
|||||||
import { readFileSync } from 'fs'
|
import { readFileSync } from 'fs'
|
||||||
|
import { resolve, dirname } from 'path'
|
||||||
|
import { fileURLToPath } from 'url'
|
||||||
|
|
||||||
const SUPABASE_URL = 'http://192.168.2.220:8000'
|
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||||
const ANON_KEY = readFileSync('/home/tophe/10-Projets/DevOps/OpenCode/GestionDesCompetences/.env', 'utf8')
|
const envPath = resolve(__dirname, '..', '.env')
|
||||||
.split('\n')
|
const envContent = readFileSync(envPath, 'utf8')
|
||||||
.find(l => l.startsWith('VITE_SUPABASE_ANON_KEY='))
|
|
||||||
?.split('=').slice(1).join('=')
|
function readEnv(key) {
|
||||||
const SERVICE_ROLE_KEY = 'eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImJmMzI4YWViLTYwMWYtNGEzZC04MjdiLTY1MTZlZTY0MWViMyJ9.eyJyb2xlIjoic2VydmljZV9yb2xlIiwiaXNzIjoic3VwYWJhc2UiLCJpYXQiOjE3NzkzMDAyNDMsImV4cCI6MTkzNjk4MDI0M30.qt2IKVgwaQQkHIZVWH4tEcrozU0mT3F9dNC9Yo83UidKwsoxHRqZz8hBWjreRPsThUcCgjxOmhwxeTB7Zd7RFA'
|
const line = envContent.split('\n').find(l => l.startsWith(`${key}=`))
|
||||||
|
return line?.split('=').slice(1).join('=')?.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
const SUPABASE_URL = readEnv('VITE_SUPABASE_URL')
|
||||||
|
const ANON_KEY = readEnv('VITE_SUPABASE_ANON_KEY')
|
||||||
|
const SERVICE_ROLE_KEY = readEnv('VITE_SUPABASE_SERVICE_ROLE_KEY')
|
||||||
|
|
||||||
|
if (!SUPABASE_URL || !ANON_KEY || !SERVICE_ROLE_KEY) {
|
||||||
|
console.error('Missing required env vars in .env. Need VITE_SUPABASE_URL, VITE_SUPABASE_ANON_KEY, VITE_SUPABASE_SERVICE_ROLE_KEY')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
const headers = {
|
const headers = {
|
||||||
'apikey': ANON_KEY,
|
'apikey': ANON_KEY,
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"skills": {
|
"skills": {
|
||||||
|
"grill-me": {
|
||||||
|
"source": "mattpocock/skills",
|
||||||
|
"sourceType": "github",
|
||||||
|
"skillPath": "skills/productivity/grill-me/SKILL.md",
|
||||||
|
"computedHash": "784f0dbb7403b0f00324bce9a112f715342777a0daee7bbb7385f9c6f0a170ea"
|
||||||
|
},
|
||||||
"pptx": {
|
"pptx": {
|
||||||
"source": "anthropics/skills",
|
"source": "anthropics/skills",
|
||||||
"sourceType": "github",
|
"sourceType": "github",
|
||||||
|
|||||||
+42
-27
@@ -1,21 +1,30 @@
|
|||||||
|
import { lazy, Suspense } from 'react'
|
||||||
import { BrowserRouter, Routes, Route } from 'react-router-dom'
|
import { BrowserRouter, Routes, Route } from 'react-router-dom'
|
||||||
import { AuthProvider } from '@/context/AuthContext'
|
import { AuthProvider } from '@/context/AuthContext'
|
||||||
import { ProtectedRoute, AdminRoute } from '@/components/ProtectedRoute'
|
import { ProtectedRoute, AdminRoute } from '@/components/ProtectedRoute'
|
||||||
|
import { ErrorBoundary } from '@/components/ErrorBoundary'
|
||||||
import { Layout } from '@/components/Layout'
|
import { Layout } from '@/components/Layout'
|
||||||
import { Toaster } from 'sonner'
|
import { Toaster } from 'sonner'
|
||||||
import { Login } from '@/pages/Login'
|
import { Login } from '@/pages/Login'
|
||||||
import { Register } from '@/pages/Register'
|
import { Register } from '@/pages/Register'
|
||||||
import { AcceptInvite } from '@/pages/AcceptInvite'
|
import { AcceptInvite } from '@/pages/AcceptInvite'
|
||||||
import { Dashboard } from '@/pages/Dashboard'
|
import { Dashboard } from '@/pages/Dashboard'
|
||||||
import { Members } from '@/pages/Members'
|
|
||||||
import { Skills } from '@/pages/Skills'
|
|
||||||
import { SkillMatrix } from '@/pages/SkillMatrix'
|
import { SkillMatrix } from '@/pages/SkillMatrix'
|
||||||
import { History } from '@/pages/History'
|
import { History } from '@/pages/History'
|
||||||
import { Profile } from '@/pages/Profile'
|
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 }) {
|
function AppLayout({ children }) {
|
||||||
|
return <Layout>{children}</Layout>
|
||||||
|
}
|
||||||
|
|
||||||
|
function SuspenseWrapper({ children }) {
|
||||||
return (
|
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 (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<Toaster />
|
<ErrorBoundary>
|
||||||
<Routes>
|
<Toaster />
|
||||||
<Route path="/login" element={<Login />} />
|
<Routes>
|
||||||
<Route path="/register" element={<Register />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="/accept-invite" element={<AcceptInvite />} />
|
<Route path="/register" element={<Register />} />
|
||||||
|
<Route path="/accept-invite" element={<AcceptInvite />} />
|
||||||
|
|
||||||
<Route path="/" element={
|
<Route path="/" element={
|
||||||
<ProtectedRoute><AppLayout><Dashboard /></AppLayout></ProtectedRoute>
|
<ProtectedRoute><AppLayout><Dashboard /></AppLayout></ProtectedRoute>
|
||||||
} />
|
} />
|
||||||
<Route path="/matrix" element={
|
<Route path="/matrix" element={
|
||||||
<ProtectedRoute><AppLayout><SkillMatrix /></AppLayout></ProtectedRoute>
|
<ProtectedRoute><AppLayout><SkillMatrix /></AppLayout></ProtectedRoute>
|
||||||
} />
|
} />
|
||||||
<Route path="/history" element={
|
<Route path="/history" element={
|
||||||
<ProtectedRoute><AppLayout><History /></AppLayout></ProtectedRoute>
|
<ProtectedRoute><AppLayout><History /></AppLayout></ProtectedRoute>
|
||||||
} />
|
} />
|
||||||
<Route path="/profile" element={
|
<Route path="/profile" element={
|
||||||
<ProtectedRoute><AppLayout><Profile /></AppLayout></ProtectedRoute>
|
<ProtectedRoute><AppLayout><Profile /></AppLayout></ProtectedRoute>
|
||||||
} />
|
} />
|
||||||
|
|
||||||
<Route path="/members" element={
|
<Route path="/members" element={
|
||||||
<ProtectedRoute><AdminRoute><AppLayout><Members /></AppLayout></AdminRoute></ProtectedRoute>
|
<ProtectedRoute><AdminRoute><AppLayout>
|
||||||
} />
|
<SuspenseWrapper><Members /></SuspenseWrapper>
|
||||||
<Route path="/skills" element={
|
</AppLayout></AdminRoute></ProtectedRoute>
|
||||||
<ProtectedRoute><AdminRoute><AppLayout><Skills /></AppLayout></AdminRoute></ProtectedRoute>
|
} />
|
||||||
} />
|
<Route path="/skills" element={
|
||||||
</Routes>
|
<ProtectedRoute><AdminRoute><AppLayout>
|
||||||
|
<SuspenseWrapper><Skills /></SuspenseWrapper>
|
||||||
|
</AppLayout></AdminRoute></ProtectedRoute>
|
||||||
|
} />
|
||||||
|
</Routes>
|
||||||
|
</ErrorBoundary>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { Component } from 'react'
|
||||||
|
|
||||||
|
export class ErrorBoundary extends Component {
|
||||||
|
constructor(props) {
|
||||||
|
super(props)
|
||||||
|
this.state = { hasError: false, error: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error) {
|
||||||
|
return { hasError: true, error }
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
if (this.state.hasError) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||||
|
<div className="text-center space-y-4">
|
||||||
|
<h1 className="text-2xl font-bold text-red-600">Une erreur est survenue</h1>
|
||||||
|
<p className="text-gray-600">
|
||||||
|
{this.state.error?.message || 'Erreur inattendue'}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
className="px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700"
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
>
|
||||||
|
Recharger la page
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return this.props.children
|
||||||
|
}
|
||||||
|
}
|
||||||
+83
-40
@@ -1,3 +1,4 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||||
import { useAuth } from '@/context/AuthContext'
|
import { useAuth } from '@/context/AuthContext'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@@ -5,75 +6,117 @@ import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
|||||||
import {
|
import {
|
||||||
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,
|
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu'
|
} 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 = [
|
const navItems = [
|
||||||
{ to: '/', label: 'Tableau de bord', icon: '📊' },
|
{ to: '/', label: 'Tableau de bord', icon: LayoutDashboard },
|
||||||
{ to: '/skills', label: 'Compétences', icon: '📚', admin: true },
|
{ to: '/skills', label: 'Compétences', icon: BookOpen, admin: true },
|
||||||
{ to: '/members', label: 'Membres', icon: '👥', admin: true },
|
{ to: '/members', label: 'Membres', icon: Users, admin: true },
|
||||||
{ to: '/matrix', label: 'Matrice', icon: '📋' },
|
{ to: '/matrix', label: 'Matrice', icon: Table2 },
|
||||||
{ to: '/history', label: 'Historique', icon: '📜' },
|
{ to: '/history', label: 'Historique', icon: History },
|
||||||
]
|
]
|
||||||
|
|
||||||
export function Layout({ children }) {
|
export function Layout({ children }) {
|
||||||
const { profile, signOut } = useAuth()
|
const { profile, signOut } = useAuth()
|
||||||
|
const { theme, setTheme } = useTheme()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||||
|
|
||||||
async function handleSignOut() {
|
async function handleSignOut() {
|
||||||
await signOut()
|
await signOut()
|
||||||
navigate('/login')
|
navigate('/login')
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
const sidebar = (
|
||||||
<div className="min-h-screen flex">
|
<aside className={`w-64 bg-gray-900 text-white flex flex-col shrink-0 ${sidebarOpen ? 'fixed inset-0 z-50' : 'hidden lg:flex'}`}>
|
||||||
<aside className="w-64 bg-gray-900 text-white flex flex-col">
|
<div className="p-4 border-b border-gray-700 flex items-center justify-between">
|
||||||
<div className="p-4 border-b border-gray-700">
|
<div>
|
||||||
<h1 className="text-lg font-bold">Compétences</h1>
|
<h1 className="text-lg font-bold">Compétences</h1>
|
||||||
<p className="text-xs text-gray-400">Équipe SysAdmin</p>
|
<p className="text-xs text-gray-400">Équipe SysAdmin</p>
|
||||||
</div>
|
</div>
|
||||||
<nav className="flex-1 p-2 space-y-1">
|
{sidebarOpen && (
|
||||||
{navItems
|
<button className="lg:hidden text-gray-400 hover:text-white" onClick={() => setSidebarOpen(false)}>
|
||||||
.filter((item) => !item.admin || profile?.role === 'admin')
|
<X className="h-5 w-5" />
|
||||||
.map((item) => (
|
</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
|
<Link
|
||||||
key={item.to}
|
key={item.to}
|
||||||
to={item.to}
|
to={item.to}
|
||||||
|
onClick={() => setSidebarOpen(false)}
|
||||||
className={`flex items-center gap-3 px-3 py-2 rounded-md text-sm transition-colors ${
|
className={`flex items-center gap-3 px-3 py-2 rounded-md text-sm transition-colors ${
|
||||||
location.pathname === item.to
|
location.pathname === item.to
|
||||||
? 'bg-gray-700 text-white'
|
? 'bg-gray-700 text-white'
|
||||||
: 'text-gray-300 hover:bg-gray-800 hover:text-white'
|
: 'text-gray-300 hover:bg-gray-800 hover:text-white'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span>{item.icon}</span>
|
<Icon className="h-4 w-4" />
|
||||||
{item.label}
|
{item.label}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
)
|
||||||
</nav>
|
})}
|
||||||
<div className="p-4 border-t border-gray-700">
|
</nav>
|
||||||
<DropdownMenu>
|
<div className="p-4 border-t border-gray-700 space-y-2">
|
||||||
<DropdownMenuTrigger asChild>
|
<button
|
||||||
<Button variant="ghost" className="w-full flex items-center gap-2 text-gray-300 hover:text-white">
|
className="flex items-center gap-2 text-sm text-gray-400 hover:text-white w-full"
|
||||||
<Avatar className="h-6 w-6">
|
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
|
||||||
<AvatarFallback className="text-xs">
|
>
|
||||||
{profile?.full_name?.charAt(0)?.toUpperCase() || '?'}
|
{theme === 'dark' ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||||
</AvatarFallback>
|
{theme === 'dark' ? 'Mode clair' : 'Mode sombre'}
|
||||||
</Avatar>
|
</button>
|
||||||
<span className="text-sm truncate">{profile?.full_name || profile?.email}</span>
|
<DropdownMenu>
|
||||||
</Button>
|
<DropdownMenuTrigger asChild>
|
||||||
</DropdownMenuTrigger>
|
<Button variant="ghost" className="w-full flex items-center gap-2 text-gray-300 hover:text-white">
|
||||||
<DropdownMenuContent align="end" className="w-48">
|
<Avatar className="h-6 w-6">
|
||||||
<DropdownMenuItem onClick={() => navigate('/profile')}>
|
<AvatarFallback className="text-xs">
|
||||||
Mon profil
|
{profile?.full_name?.charAt(0)?.toUpperCase() || '?'}
|
||||||
</DropdownMenuItem>
|
</AvatarFallback>
|
||||||
<DropdownMenuItem onClick={handleSignOut}>
|
</Avatar>
|
||||||
Déconnexion
|
<span className="text-sm truncate">{profile?.full_name || profile?.email}</span>
|
||||||
</DropdownMenuItem>
|
</Button>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuTrigger>
|
||||||
</DropdownMenu>
|
<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>
|
</div>
|
||||||
</aside>
|
|
||||||
<main className="flex-1 bg-gray-50 p-8 overflow-auto">
|
|
||||||
{children}
|
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable react-refresh/only-export-components */
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
|
||||||
const levelConfig = {
|
const levelConfig = {
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import {
|
||||||
|
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend,
|
||||||
|
} from 'recharts'
|
||||||
|
|
||||||
|
const levelLabels = {
|
||||||
|
1: { label: 'Débutant', color: '#9ca3af' },
|
||||||
|
2: { label: 'Intermédiaire', color: '#60a5fa' },
|
||||||
|
3: { label: 'Avancé', color: '#fbbf24' },
|
||||||
|
4: { label: 'Expert', color: '#34d399' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BarChartView({
|
||||||
|
categories, skills: allSkills, members, levels, filterCat,
|
||||||
|
onBarClick,
|
||||||
|
}) {
|
||||||
|
const cats = filterCat === 'all' ? categories : categories.filter((c) => c.id === filterCat)
|
||||||
|
|
||||||
|
const data = cats.map((cat) => {
|
||||||
|
const catSkills = allSkills.filter((s) => s.category_id === cat.id)
|
||||||
|
const buckets = { 1: 0, 2: 0, 3: 0, 4: 0 }
|
||||||
|
members.forEach((m) => {
|
||||||
|
catSkills.forEach((s) => {
|
||||||
|
const key = `${m.id}-${s.id}`
|
||||||
|
const lvl = levels[key]?.level
|
||||||
|
if (lvl) buckets[lvl]++
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
name: cat.name,
|
||||||
|
color: cat.color,
|
||||||
|
id: cat.id,
|
||||||
|
...buckets,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return <div className="p-8 text-center text-gray-400">Aucune donnée à afficher</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClick(entry, _index, level) {
|
||||||
|
if (onBarClick && entry?.id) {
|
||||||
|
onBarClick(entry.id, level)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-950 rounded-lg p-4">
|
||||||
|
<ResponsiveContainer width="100%" height={400}>
|
||||||
|
<BarChart data={data} layout="vertical" margin={{ top: 20, right: 30, left: 80, bottom: 5 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" horizontal={false} />
|
||||||
|
<XAxis type="number" stroke="var(--foreground)" tick={{ fontSize: 12 }} />
|
||||||
|
<YAxis type="category" dataKey="name" stroke="var(--foreground)" tick={{ fontSize: 12 }} width={100} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
background: 'var(--background)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius)',
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Legend
|
||||||
|
payload={[1, 2, 3, 4].map((l) => ({
|
||||||
|
value: `${l} - ${levelLabels[l].label}`,
|
||||||
|
type: 'rect',
|
||||||
|
color: levelLabels[l].color,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
{[1, 2, 3, 4].map((lvl) => (
|
||||||
|
<Bar
|
||||||
|
key={lvl}
|
||||||
|
dataKey={lvl}
|
||||||
|
stackId="a"
|
||||||
|
fill={levelLabels[lvl].color}
|
||||||
|
name={`Niveau ${lvl}`}
|
||||||
|
cursor="pointer"
|
||||||
|
onClick={(entry) => handleClick(entry, null, lvl)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { forceSimulation, forceLink, forceManyBody, forceCenter, forceCollide } from 'd3-force'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
|
export default function GraphView({
|
||||||
|
categories, skills: allSkills, levels,
|
||||||
|
filteredSkills, filteredMembers,
|
||||||
|
}) {
|
||||||
|
const canvasRef = useRef(null)
|
||||||
|
const [aggregated, setAggregated] = useState(false)
|
||||||
|
const [selectedNode, setSelectedNode] = useState(null)
|
||||||
|
const width = 900
|
||||||
|
const height = 600
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current
|
||||||
|
if (!canvas) return
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (!ctx) return
|
||||||
|
|
||||||
|
const catMap = {}
|
||||||
|
categories.forEach((c) => { catMap[c.id] = c })
|
||||||
|
|
||||||
|
const nodes = []
|
||||||
|
const links = []
|
||||||
|
const nodeMap = new Map()
|
||||||
|
|
||||||
|
filteredMembers.forEach((m) => {
|
||||||
|
const node = { id: m.id, type: 'member', label: m.full_name || m.email, r: 10, x: width / 2, y: height / 2 }
|
||||||
|
nodes.push(node)
|
||||||
|
nodeMap.set(m.id, node)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (aggregated) {
|
||||||
|
categories
|
||||||
|
.filter((cat) => filteredSkills.length === 0 || filteredSkills.some((s) => s.category_id === cat.id))
|
||||||
|
.forEach((cat) => {
|
||||||
|
const nid = `cat-${cat.id}`
|
||||||
|
const node = { id: nid, type: 'category', label: cat.name, r: 14, color: cat.color, catId: cat.id, x: width / 2, y: height / 2 }
|
||||||
|
nodes.push(node)
|
||||||
|
nodeMap.set(nid, node)
|
||||||
|
|
||||||
|
filteredMembers.forEach((m) => {
|
||||||
|
const catSkillIds = allSkills.filter((s) => s.category_id === cat.id).map((s) => s.id)
|
||||||
|
let total = 0
|
||||||
|
let count = 0
|
||||||
|
catSkillIds.forEach((sid) => {
|
||||||
|
const key = `${m.id}-${sid}`
|
||||||
|
const lvl = levels[key]?.level
|
||||||
|
if (lvl) { total += lvl; count++ }
|
||||||
|
})
|
||||||
|
if (count > 0) {
|
||||||
|
links.push({ source: m.id, target: nid, strength: total / count / 4 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
filteredSkills.forEach((s) => {
|
||||||
|
const nid = `skill-${s.id}`
|
||||||
|
const node = { id: nid, type: 'skill', label: s.name, r: 8, color: catMap[s.category_id]?.color || '#888', skillId: s.id, x: width / 2, y: height / 2 }
|
||||||
|
nodes.push(node)
|
||||||
|
nodeMap.set(nid, node)
|
||||||
|
})
|
||||||
|
|
||||||
|
filteredMembers.forEach((m) => {
|
||||||
|
filteredSkills.forEach((s) => {
|
||||||
|
const key = `${m.id}-${s.id}`
|
||||||
|
const lvl = levels[key]?.level
|
||||||
|
if (lvl) {
|
||||||
|
links.push({ source: m.id, target: `skill-${s.id}`, strength: lvl / 4 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nodes.length === 0) return
|
||||||
|
|
||||||
|
const dpi = window.devicePixelRatio || 1
|
||||||
|
canvas.width = width * dpi
|
||||||
|
canvas.height = height * dpi
|
||||||
|
ctx.scale(dpi, dpi)
|
||||||
|
|
||||||
|
const borderColor = getComputedStyle(canvas).getPropertyValue('--border').trim() || '#e5e7eb'
|
||||||
|
const fgColor = getComputedStyle(canvas).getPropertyValue('--foreground').trim() || '#000'
|
||||||
|
|
||||||
|
const simulation = forceSimulation(nodes)
|
||||||
|
.force('link', forceLink(links).id((d) => d.id).distance(120).strength((d) => d.strength || 0.3))
|
||||||
|
.force('charge', forceManyBody().strength(-150))
|
||||||
|
.force('center', forceCenter(width / 2, height / 2))
|
||||||
|
.force('collide', forceCollide(20))
|
||||||
|
.on('tick', ticked)
|
||||||
|
|
||||||
|
function ticked() {
|
||||||
|
ctx.clearRect(0, 0, width, height)
|
||||||
|
|
||||||
|
ctx.strokeStyle = borderColor
|
||||||
|
ctx.lineWidth = 1
|
||||||
|
links.forEach((l) => {
|
||||||
|
const s = l.strength || 0.3
|
||||||
|
ctx.globalAlpha = s * 0.5 + 0.15
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(l.source.x, l.source.y)
|
||||||
|
ctx.lineTo(l.target.x, l.target.y)
|
||||||
|
ctx.stroke()
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx.globalAlpha = 1
|
||||||
|
nodes.forEach((n) => {
|
||||||
|
const isSel = selectedNode === n.id
|
||||||
|
const isNeighbor = isSel
|
||||||
|
? links.some((l) => {
|
||||||
|
const sid = typeof l.source === 'object' ? l.source.id : l.source
|
||||||
|
const tid = typeof l.target === 'object' ? l.target.id : l.target
|
||||||
|
return (sid === selectedNode && tid === n.id) || (tid === selectedNode && sid === n.id)
|
||||||
|
})
|
||||||
|
: false
|
||||||
|
|
||||||
|
ctx.globalAlpha = selectedNode && !isSel && !isNeighbor ? 0.12 : 1
|
||||||
|
|
||||||
|
if (n.type === 'member') {
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(n.x, n.y, n.r, 0, 2 * Math.PI)
|
||||||
|
ctx.fillStyle = '#6366f1'
|
||||||
|
ctx.fill()
|
||||||
|
if (isSel) {
|
||||||
|
ctx.strokeStyle = '#fff'
|
||||||
|
ctx.lineWidth = 2
|
||||||
|
ctx.stroke()
|
||||||
|
}
|
||||||
|
} else if (n.type === 'category') {
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(n.x, n.y, n.r, 0, 2 * Math.PI)
|
||||||
|
ctx.fillStyle = n.color || '#888'
|
||||||
|
ctx.fill()
|
||||||
|
if (isSel) {
|
||||||
|
ctx.strokeStyle = '#fff'
|
||||||
|
ctx.lineWidth = 2
|
||||||
|
ctx.stroke()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const s = 6
|
||||||
|
ctx.fillStyle = n.color || '#888'
|
||||||
|
ctx.fillRect(n.x - s / 2, n.y - s / 2, s, s)
|
||||||
|
if (isSel) {
|
||||||
|
ctx.strokeStyle = '#fff'
|
||||||
|
ctx.lineWidth = 2
|
||||||
|
ctx.strokeRect(n.x - s / 2, n.y - s / 2, s, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.fillStyle = fgColor
|
||||||
|
ctx.font = '10px sans-serif'
|
||||||
|
ctx.textAlign = 'center'
|
||||||
|
ctx.fillText(n.label, n.x, n.y + n.r + 12)
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx.globalAlpha = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
let dragNode = null
|
||||||
|
|
||||||
|
function getCanvasPos(e) {
|
||||||
|
const rect = canvas.getBoundingClientRect()
|
||||||
|
return {
|
||||||
|
x: (e.clientX - rect.left) * (width / rect.width),
|
||||||
|
y: (e.clientY - rect.top) * (height / rect.height),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function findHit(px, py) {
|
||||||
|
for (let i = nodes.length - 1; i >= 0; i--) {
|
||||||
|
const n = nodes[i]
|
||||||
|
const dx = px - n.x
|
||||||
|
const dy = py - n.y
|
||||||
|
if (dx * dx + dy * dy <= (n.r + 8) * (n.r + 8)) return n
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerDown(e) {
|
||||||
|
const pos = getCanvasPos(e)
|
||||||
|
const hit = findHit(pos.x, pos.y)
|
||||||
|
if (hit) {
|
||||||
|
dragNode = hit
|
||||||
|
setSelectedNode(hit.id)
|
||||||
|
hit.fx = hit.x
|
||||||
|
hit.fy = hit.y
|
||||||
|
simulation.alphaTarget(0.3).restart()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerMove(e) {
|
||||||
|
if (!dragNode) return
|
||||||
|
const pos = getCanvasPos(e)
|
||||||
|
dragNode.fx = Math.max(0, Math.min(width, pos.x))
|
||||||
|
dragNode.fy = Math.max(0, Math.min(height, pos.y))
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerUp() {
|
||||||
|
if (dragNode) {
|
||||||
|
dragNode.fx = null
|
||||||
|
dragNode.fy = null
|
||||||
|
dragNode = null
|
||||||
|
simulation.alphaTarget(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.addEventListener('pointerdown', onPointerDown)
|
||||||
|
window.addEventListener('pointermove', onPointerMove)
|
||||||
|
window.addEventListener('pointerup', onPointerUp)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
simulation.stop()
|
||||||
|
canvas.removeEventListener('pointerdown', onPointerDown)
|
||||||
|
window.removeEventListener('pointermove', onPointerMove)
|
||||||
|
window.removeEventListener('pointerup', onPointerUp)
|
||||||
|
}
|
||||||
|
}, [filteredSkills, filteredMembers, levels, categories, aggregated, selectedNode, allSkills])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||||
|
<div className="flex items-center gap-3 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<span className="w-3 h-3 rounded-full bg-indigo-500 inline-block" /> Membre
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<span className="w-3 h-3 rounded-sm bg-gray-400 inline-block" /> Compétence
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<span className="w-3 h-3 rounded-full border border-current inline-block" /> Catégorie
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setAggregated(!aggregated)}>
|
||||||
|
{aggregated ? 'Détailler les compétences' : 'Agréger par catégorie'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-950 rounded-lg overflow-hidden relative">
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
className="block w-full touch-none"
|
||||||
|
style={{ height: `${height}px`, cursor: 'grab' }}
|
||||||
|
/>
|
||||||
|
<div className="absolute bottom-3 left-1/2 -translate-x-1/2 text-xs text-gray-400 pointer-events-none">
|
||||||
|
Cliquez sur un nœud pour le sélectionner • Glissez pour déplacer
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { ChevronDown, ChevronRight, ListCollapse } from 'lucide-react'
|
||||||
|
import { SkillLevelSelect } from '@/components/SkillLevelBadge'
|
||||||
|
|
||||||
|
const heatColors = {
|
||||||
|
0: 'bg-gray-50 dark:bg-gray-900',
|
||||||
|
1: 'bg-gray-200 dark:bg-gray-700',
|
||||||
|
2: 'bg-blue-200 dark:bg-blue-900',
|
||||||
|
3: 'bg-amber-200 dark:bg-amber-900',
|
||||||
|
4: 'bg-green-200 dark:bg-green-900',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HeatmapView({
|
||||||
|
categories, levels,
|
||||||
|
isAdmin, currentUserId,
|
||||||
|
editing, onEdit, onUpdate, onCancel,
|
||||||
|
filteredSkills, visibleMembers,
|
||||||
|
}) {
|
||||||
|
const [collapsed, setCollapsed] = useState(new Set())
|
||||||
|
|
||||||
|
const grouped = categories
|
||||||
|
.map((cat) => ({
|
||||||
|
...cat,
|
||||||
|
catSkills: filteredSkills.filter((s) => s.category_id === cat.id),
|
||||||
|
}))
|
||||||
|
.filter((g) => g.catSkills.length > 0)
|
||||||
|
|
||||||
|
const allCollapsed = grouped.every((g) => collapsed.has(g.id))
|
||||||
|
|
||||||
|
function toggleCategory(catId) {
|
||||||
|
setCollapsed((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(catId)) next.delete(catId)
|
||||||
|
else next.add(catId)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAll() {
|
||||||
|
if (allCollapsed) {
|
||||||
|
setCollapsed(new Set())
|
||||||
|
} else {
|
||||||
|
setCollapsed(new Set(grouped.map((g) => g.id)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (visibleMembers.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="p-8 text-center text-gray-400">
|
||||||
|
Aucun résultat
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-auto">
|
||||||
|
<table className="w-full border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="text-left p-2 bg-gray-100 dark:bg-gray-800 border dark:border-gray-700 sticky left-0 z-10 min-w-[180px]">
|
||||||
|
<button
|
||||||
|
className="inline-flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 mr-2"
|
||||||
|
onClick={toggleAll}
|
||||||
|
title={allCollapsed ? 'Tout dérouler' : 'Tout replier'}
|
||||||
|
>
|
||||||
|
<ListCollapse className={`h-3.5 w-3.5 transition-transform ${allCollapsed ? '' : 'rotate-180'}`} />
|
||||||
|
</button>
|
||||||
|
Compétence
|
||||||
|
</th>
|
||||||
|
{visibleMembers.map((m) => (
|
||||||
|
<th key={m.id} className="p-2 bg-gray-100 dark:bg-gray-800 border dark:border-gray-700 text-sm text-center min-w-[120px]">
|
||||||
|
{m.full_name || m.email}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{grouped.map((g) => {
|
||||||
|
const isCollapsed = collapsed.has(g.id)
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<tr key={g.id} className="bg-gray-50 dark:bg-gray-800/50 hover:bg-gray-100 dark:hover:bg-gray-800">
|
||||||
|
<td className="p-2 border dark:border-gray-700 font-semibold text-sm sticky left-0 bg-gray-50 dark:bg-gray-800/50 cursor-pointer" onClick={() => toggleCategory(g.id)}>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
{isCollapsed ? <ChevronRight className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||||
|
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: g.color }} />
|
||||||
|
{g.name}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
{visibleMembers.map((m) => {
|
||||||
|
const dotColors = ['bg-gray-200', 'bg-blue-200', 'bg-amber-200', 'bg-green-200']
|
||||||
|
const counts = [0, 0, 0, 0]
|
||||||
|
g.catSkills.forEach((s) => {
|
||||||
|
const lvl = levels[`${m.id}-${s.id}`]?.level
|
||||||
|
if (lvl) counts[lvl - 1]++
|
||||||
|
})
|
||||||
|
return (
|
||||||
|
<td key={m.id} className="p-2 border dark:border-gray-700 text-center bg-gray-50 dark:bg-gray-800/50">
|
||||||
|
<span className="inline-flex items-center gap-2 text-xs">
|
||||||
|
{counts.map((c, i) =>
|
||||||
|
c > 0 ? (
|
||||||
|
<span key={i} className="inline-flex items-center gap-0.5">
|
||||||
|
<span className={'inline-block w-2.5 h-2.5 rounded-full ' + dotColors[i]} />
|
||||||
|
<span className="font-semibold">{c}</span>
|
||||||
|
</span>
|
||||||
|
) : null
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
{!isCollapsed && g.catSkills.map((s) => (
|
||||||
|
<tr key={s.id} className="hover:bg-gray-50 dark:hover:bg-gray-800/50">
|
||||||
|
<td className="p-2 border dark:border-gray-700 font-medium sticky left-0 bg-white dark:bg-gray-950">
|
||||||
|
<span className="text-sm pl-6">{s.name}</span>
|
||||||
|
</td>
|
||||||
|
{visibleMembers.map((m) => {
|
||||||
|
const key = `${m.id}-${s.id}`
|
||||||
|
const level = levels[key]
|
||||||
|
const lvl = level?.level || 0
|
||||||
|
const canEditCell = isAdmin || currentUserId === m.id
|
||||||
|
const isEditing = editing === key
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
key={m.id}
|
||||||
|
className={`p-2 border dark:border-gray-700 text-center ${heatColors[lvl]} ${currentUserId === m.id ? 'ring-2 ring-blue-400 dark:ring-blue-600 ring-inset' : ''}`}
|
||||||
|
>
|
||||||
|
{isEditing && canEditCell ? (
|
||||||
|
<SkillLevelSelect
|
||||||
|
value={level?.level || 1}
|
||||||
|
onChange={(v) => onUpdate(m.id, s.id, v)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="text-sm font-medium cursor-pointer"
|
||||||
|
onClick={() => canEditCell && onEdit(key)}
|
||||||
|
>
|
||||||
|
{lvl || '—'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isEditing && canEditCell && (
|
||||||
|
<button className="ml-1 text-xs text-red-500" onClick={onCancel}>✕</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import {
|
||||||
|
RadarChart, PolarGrid, PolarAngleAxis, PolarRadiusAxis, Radar, Tooltip, ResponsiveContainer, Legend,
|
||||||
|
} from 'recharts'
|
||||||
|
|
||||||
|
export default function RadarView({
|
||||||
|
categories, skills: allSkills, members, levels, filterMember,
|
||||||
|
}) {
|
||||||
|
const [selectedMember, setSelectedMember] = useState(filterMember !== 'all' ? filterMember : (members[0]?.id || ''))
|
||||||
|
|
||||||
|
function getCategoryAvg(catId) {
|
||||||
|
const catSkills = allSkills.filter((s) => s.category_id === catId)
|
||||||
|
if (catSkills.length === 0) return 0
|
||||||
|
let total = 0
|
||||||
|
let count = 0
|
||||||
|
members.forEach((m) => {
|
||||||
|
catSkills.forEach((s) => {
|
||||||
|
const key = `${m.id}-${s.id}`
|
||||||
|
const lvl = levels[key]?.level
|
||||||
|
if (lvl) { total += lvl; count++ }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return count > 0 ? +(total / count).toFixed(1) : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMemberAvg(catId, memberId) {
|
||||||
|
const catSkills = allSkills.filter((s) => s.category_id === catId)
|
||||||
|
if (catSkills.length === 0) return 0
|
||||||
|
let total = 0
|
||||||
|
let count = 0
|
||||||
|
catSkills.forEach((s) => {
|
||||||
|
const key = `${memberId}-${s.id}`
|
||||||
|
const lvl = levels[key]?.level
|
||||||
|
if (lvl) { total += lvl; count++ }
|
||||||
|
})
|
||||||
|
return count > 0 ? +(total / count).toFixed(1) : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = categories.map((cat) => {
|
||||||
|
const teamAvg = getCategoryAvg(cat.id)
|
||||||
|
const memberAvg = selectedMember ? getMemberAvg(cat.id, selectedMember) : 0
|
||||||
|
return {
|
||||||
|
category: cat.name,
|
||||||
|
color: cat.color,
|
||||||
|
teamAvg,
|
||||||
|
memberAvg,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedMemberName = members.find((m) => m.id === selectedMember)?.full_name || 'Membre'
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return <div className="p-8 text-center text-gray-400">Aucune donnée à afficher</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-950 rounded-lg p-4">
|
||||||
|
<div className="flex items-center gap-4 mb-4">
|
||||||
|
<label className="text-sm text-gray-600 dark:text-gray-400">Membre :</label>
|
||||||
|
<select
|
||||||
|
className="border rounded px-2 py-1 text-sm dark:bg-gray-800 dark:border-gray-700"
|
||||||
|
value={selectedMember}
|
||||||
|
onChange={(e) => setSelectedMember(e.target.value)}
|
||||||
|
>
|
||||||
|
{members.map((m) => (
|
||||||
|
<option key={m.id} value={m.id}>{m.full_name || m.email}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ResponsiveContainer width="100%" height={450}>
|
||||||
|
<RadarChart data={data}>
|
||||||
|
<PolarGrid stroke="var(--border)" />
|
||||||
|
<PolarAngleAxis dataKey="category" stroke="var(--foreground)" tick={{ fontSize: 11 }} />
|
||||||
|
<PolarRadiusAxis domain={[0, 4]} tickCount={5} stroke="var(--border)" tick={{ fontSize: 10 }} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
background: 'var(--background)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius)',
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Radar
|
||||||
|
name="Moyenne équipe"
|
||||||
|
dataKey="teamAvg"
|
||||||
|
stroke="var(--muted-foreground)"
|
||||||
|
fill="var(--muted-foreground)"
|
||||||
|
fillOpacity={0.1}
|
||||||
|
strokeDasharray="4 4"
|
||||||
|
/>
|
||||||
|
<Radar
|
||||||
|
name={selectedMemberName}
|
||||||
|
dataKey="memberAvg"
|
||||||
|
stroke="var(--foreground)"
|
||||||
|
fill="var(--foreground)"
|
||||||
|
fillOpacity={0.15}
|
||||||
|
/>
|
||||||
|
<Legend />
|
||||||
|
</RadarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import {
|
||||||
|
ScatterChart, Scatter, XAxis, YAxis, ZAxis, CartesianGrid,
|
||||||
|
Tooltip, ResponsiveContainer, Legend,
|
||||||
|
} from 'recharts'
|
||||||
|
|
||||||
|
export default function ScatterView({
|
||||||
|
members, levels, categories,
|
||||||
|
filterMember,
|
||||||
|
filteredSkills, onMemberSelect,
|
||||||
|
}) {
|
||||||
|
const data = []
|
||||||
|
const catGaps = {}
|
||||||
|
let xPos = 0
|
||||||
|
|
||||||
|
categories.forEach((cat) => {
|
||||||
|
const catSkills = filteredSkills.filter((s) => s.category_id === cat.id)
|
||||||
|
if (catSkills.length === 0) return
|
||||||
|
xPos += 1
|
||||||
|
catGaps[cat.id] = { start: xPos, end: xPos + catSkills.length - 1 }
|
||||||
|
catSkills.forEach((s) => {
|
||||||
|
const x = xPos++
|
||||||
|
members.forEach((m) => {
|
||||||
|
if (filterMember !== 'all' && m.id !== filterMember) return
|
||||||
|
const key = `${m.id}-${s.id}`
|
||||||
|
const lvl = levels[key]?.level
|
||||||
|
if (!lvl) return
|
||||||
|
data.push({
|
||||||
|
x,
|
||||||
|
y: lvl + (Math.random() - 0.5) * 0.3,
|
||||||
|
memberName: m.full_name || m.email,
|
||||||
|
skillName: s.name,
|
||||||
|
level: lvl,
|
||||||
|
category: cat.name,
|
||||||
|
categoryColor: cat.color,
|
||||||
|
memberId: m.id,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const ticks = categories
|
||||||
|
.filter((cat) => catGaps[cat.id])
|
||||||
|
.map((cat) => ({
|
||||||
|
value: (catGaps[cat.id].start + catGaps[cat.id].end) / 2,
|
||||||
|
label: cat.name,
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return <div className="p-8 text-center text-gray-400">Aucune donnée à afficher</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-950 rounded-lg p-4">
|
||||||
|
<ResponsiveContainer width="100%" height={500}>
|
||||||
|
<ScatterChart margin={{ top: 20, right: 20, bottom: 60, left: 40 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
||||||
|
<XAxis
|
||||||
|
type="number"
|
||||||
|
dataKey="x"
|
||||||
|
domain={[0, 'dataMax']}
|
||||||
|
ticks={ticks.map((t) => t.value)}
|
||||||
|
tickFormatter={(v) => ticks.find((t) => t.value === v)?.label || ''}
|
||||||
|
stroke="var(--foreground)"
|
||||||
|
tick={{ fontSize: 12 }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
type="number"
|
||||||
|
domain={[0.5, 4.5]}
|
||||||
|
ticks={[1, 2, 3, 4]}
|
||||||
|
tickFormatter={(v) => ['', '1 - Débutant', '2 - Intermédiaire', '3 - Avancé', '4 - Expert'][v]}
|
||||||
|
stroke="var(--foreground)"
|
||||||
|
tick={{ fontSize: 12 }}
|
||||||
|
/>
|
||||||
|
<ZAxis range={[60, 60]} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
background: 'var(--background)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius)',
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
formatter={(val, name) => {
|
||||||
|
if (name === 'y') return null
|
||||||
|
return [val, name]
|
||||||
|
}}
|
||||||
|
labelFormatter={() => ''}
|
||||||
|
content={({ active, payload }) => {
|
||||||
|
if (!active || !payload?.[0]) return null
|
||||||
|
const d = payload[0].payload
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-800 border rounded-lg p-3 shadow-lg text-sm space-y-1">
|
||||||
|
<p className="font-medium">{d.memberName}</p>
|
||||||
|
<p>{d.skillName}</p>
|
||||||
|
<p>Niveau : <strong>{d.level}</strong></p>
|
||||||
|
<p className="text-xs" style={{ color: d.categoryColor }}>{d.category}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Legend
|
||||||
|
payload={categories.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
value: c.name,
|
||||||
|
type: 'circle',
|
||||||
|
color: c.color,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
{categories.map((cat) => {
|
||||||
|
const catData = data.filter((d) => d.category === cat.name)
|
||||||
|
return (
|
||||||
|
<Scatter
|
||||||
|
key={cat.id}
|
||||||
|
name={cat.name}
|
||||||
|
data={catData}
|
||||||
|
fill={cat.color}
|
||||||
|
stroke="none"
|
||||||
|
onClick={(point) => onMemberSelect?.(point.memberId)}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ScatterChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
export function SkillMatrixFilters({ categories, members, filterCat, filterMember, filterMinLevel, onFilterChange, hideMember }) {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-4 flex-wrap">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="text-sm text-gray-600 dark:text-gray-400">Catégorie :</label>
|
||||||
|
<select
|
||||||
|
className="border rounded px-2 py-1 text-sm dark:bg-gray-800 dark:border-gray-700"
|
||||||
|
value={filterCat}
|
||||||
|
onChange={(e) => onFilterChange('cat', e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="all">Toutes</option>
|
||||||
|
{categories.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>{c.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{!hideMember && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="text-sm text-gray-600 dark:text-gray-400">Membre :</label>
|
||||||
|
<select
|
||||||
|
className="border rounded px-2 py-1 text-sm dark:bg-gray-800 dark:border-gray-700"
|
||||||
|
value={filterMember}
|
||||||
|
onChange={(e) => onFilterChange('member', 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 dark:text-gray-400">Niveau min. :</label>
|
||||||
|
<select
|
||||||
|
className="border rounded px-2 py-1 text-sm dark:bg-gray-800 dark:border-gray-700"
|
||||||
|
value={filterMinLevel}
|
||||||
|
onChange={(e) => onFilterChange('minLevel', 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { ChevronDown, ChevronRight, ListCollapse } from 'lucide-react'
|
||||||
|
import { SkillLevelBadge, SkillLevelSelect } from '@/components/SkillLevelBadge'
|
||||||
|
|
||||||
|
export function SkillMatrixTable({ categories, skills, members, levels, isAdmin, currentUserId, editing, onEdit, onUpdate, onCancel }) {
|
||||||
|
const [collapsed, setCollapsed] = useState(new Set())
|
||||||
|
|
||||||
|
const grouped = categories
|
||||||
|
.map((cat) => ({
|
||||||
|
...cat,
|
||||||
|
catSkills: skills.filter((s) => s.category_id === cat.id),
|
||||||
|
}))
|
||||||
|
.filter((g) => g.catSkills.length > 0)
|
||||||
|
|
||||||
|
const allCollapsed = grouped.every((g) => collapsed.has(g.id))
|
||||||
|
|
||||||
|
function toggleCategory(catId) {
|
||||||
|
setCollapsed((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(catId)) next.delete(catId)
|
||||||
|
else next.add(catId)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAll() {
|
||||||
|
if (allCollapsed) {
|
||||||
|
setCollapsed(new Set())
|
||||||
|
} else {
|
||||||
|
setCollapsed(new Set(grouped.map((g) => g.id)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (members.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="p-8 text-center text-gray-400">
|
||||||
|
Aucun résultat
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-auto">
|
||||||
|
<table className="w-full border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="text-left p-2 bg-gray-100 dark:bg-gray-800 border dark:border-gray-700 sticky left-0 z-10 min-w-[180px]">
|
||||||
|
<button
|
||||||
|
className="inline-flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 mr-2"
|
||||||
|
onClick={toggleAll}
|
||||||
|
title={allCollapsed ? 'Tout dérouler' : 'Tout replier'}
|
||||||
|
>
|
||||||
|
<ListCollapse className={`h-3.5 w-3.5 transition-transform ${allCollapsed ? '' : 'rotate-180'}`} />
|
||||||
|
</button>
|
||||||
|
Compétence
|
||||||
|
</th>
|
||||||
|
{members.map((m) => (
|
||||||
|
<th key={m.id} className="p-2 bg-gray-100 dark:bg-gray-800 border dark:border-gray-700 text-sm text-center min-w-[120px]">
|
||||||
|
{m.full_name || m.email}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{grouped.map((g) => {
|
||||||
|
const isCollapsed = collapsed.has(g.id)
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<tr key={g.id} className="bg-gray-50 dark:bg-gray-800/50 hover:bg-gray-100 dark:hover:bg-gray-800">
|
||||||
|
<td className="p-2 border dark:border-gray-700 font-semibold text-sm sticky left-0 bg-gray-50 dark:bg-gray-800/50 cursor-pointer" onClick={() => toggleCategory(g.id)}>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
{isCollapsed ? <ChevronRight className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||||
|
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: g.color }} />
|
||||||
|
{g.name}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
{members.map((m) => {
|
||||||
|
const dotColors = ['bg-gray-200', 'bg-blue-200', 'bg-amber-200', 'bg-green-200']
|
||||||
|
const counts = [0, 0, 0, 0]
|
||||||
|
g.catSkills.forEach((s) => {
|
||||||
|
const lvl = levels[`${m.id}-${s.id}`]?.level
|
||||||
|
if (lvl) counts[lvl - 1]++
|
||||||
|
})
|
||||||
|
return (
|
||||||
|
<td key={m.id} className="p-2 border dark:border-gray-700 text-center bg-gray-50 dark:bg-gray-800/50">
|
||||||
|
<span className="inline-flex items-center gap-2 text-xs">
|
||||||
|
{counts.map((c, i) =>
|
||||||
|
c > 0 ? (
|
||||||
|
<span key={i} className="inline-flex items-center gap-0.5">
|
||||||
|
<span className={'inline-block w-2.5 h-2.5 rounded-full ' + dotColors[i]} />
|
||||||
|
<span className="font-semibold">{c}</span>
|
||||||
|
</span>
|
||||||
|
) : null
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
{!isCollapsed && g.catSkills.map((s) => (
|
||||||
|
<tr key={s.id} className="hover:bg-gray-50 dark:hover:bg-gray-800/50">
|
||||||
|
<td className="p-2 border dark:border-gray-700 font-medium sticky left-0 bg-white dark:bg-gray-950">
|
||||||
|
<span className="text-sm pl-6">{s.name}</span>
|
||||||
|
</td>
|
||||||
|
{members.map((m) => {
|
||||||
|
const key = `${m.id}-${s.id}`
|
||||||
|
const level = levels[key]
|
||||||
|
const canEditCell = isAdmin || currentUserId === m.id
|
||||||
|
const isEditing = editing === key
|
||||||
|
return (
|
||||||
|
<td key={m.id} className={`p-2 border dark:border-gray-700 text-center ${currentUserId === m.id ? 'bg-blue-50 dark:bg-blue-950/20' : ''}`}>
|
||||||
|
{isEditing && canEditCell ? (
|
||||||
|
<SkillLevelSelect
|
||||||
|
value={level?.level || 1}
|
||||||
|
onChange={(v) => onUpdate(m.id, s.id, v)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
level ? (
|
||||||
|
<SkillLevelBadge
|
||||||
|
level={level.level}
|
||||||
|
onClick={() => canEditCell && onEdit(key)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="text-gray-300 dark:text-gray-600 text-sm cursor-pointer"
|
||||||
|
onClick={() => canEditCell && onEdit(key)}
|
||||||
|
>
|
||||||
|
—
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{isEditing && canEditCell && (
|
||||||
|
<button className="ml-1 text-xs text-red-500" onClick={onCancel}>✕</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { SkillLevelSelect } from '@/components/SkillLevelBadge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
|
import { Save } from 'lucide-react'
|
||||||
|
|
||||||
|
export function SkillMemberForm({ member, categories, skills, levels, isAdmin, currentUserId, onSave }) {
|
||||||
|
const canEdit = isAdmin || currentUserId === member.id
|
||||||
|
const initial = {}
|
||||||
|
skills.forEach((s) => {
|
||||||
|
const key = `${member.id}-${s.id}`
|
||||||
|
initial[s.id] = levels[key]?.level || 0
|
||||||
|
})
|
||||||
|
const [editedLevels, setEditedLevels] = useState(initial)
|
||||||
|
|
||||||
|
const hasChanges = skills.some((s) => {
|
||||||
|
const key = `${member.id}-${s.id}`
|
||||||
|
return (editedLevels[s.id] || 0) !== (levels[key]?.level || 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleSave() {
|
||||||
|
const changes = []
|
||||||
|
skills.forEach((s) => {
|
||||||
|
const key = `${member.id}-${s.id}`
|
||||||
|
const current = levels[key]?.level || 0
|
||||||
|
const edited = editedLevels[s.id] || 0
|
||||||
|
if (edited !== current) {
|
||||||
|
changes.push({ skillId: s.id, oldLevel: current || null, newLevel: edited })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (changes.length > 0) onSave(member.id, changes)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Modification des compétences de <span className="font-medium text-gray-700 dark:text-gray-200">{member.full_name || member.email}</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{categories.map((cat) => {
|
||||||
|
const catSkills = skills.filter((s) => s.category_id === cat.id)
|
||||||
|
if (catSkills.length === 0) return null
|
||||||
|
return (
|
||||||
|
<Card key={cat.id}>
|
||||||
|
<CardContent className="p-4 space-y-3">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider flex items-center gap-2">
|
||||||
|
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: cat.color }} />
|
||||||
|
{cat.name}
|
||||||
|
</h3>
|
||||||
|
{catSkills.map((s) => (
|
||||||
|
<div key={s.id} className="flex items-center justify-between py-1.5 border-b dark:border-gray-800 last:border-0">
|
||||||
|
<span className="text-sm">{s.name}</span>
|
||||||
|
{canEdit ? (
|
||||||
|
<SkillLevelSelect
|
||||||
|
value={editedLevels[s.id] || 1}
|
||||||
|
onChange={(v) => setEditedLevels((prev) => ({ ...prev, [s.id]: v }))}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{levels[`${member.id}-${s.id}`]?.level || '—'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{canEdit && (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button onClick={handleSave} disabled={!hasChanges}>
|
||||||
|
<Save className="h-4 w-4 mr-2" />
|
||||||
|
Enregistrer
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { Treemap, ResponsiveContainer, Tooltip } from 'recharts'
|
||||||
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
|
|
||||||
|
export default function TreemapView({
|
||||||
|
categories: allCategories, members, levels,
|
||||||
|
filterMember, filterCat,
|
||||||
|
filteredSkills, onCellClick,
|
||||||
|
}) {
|
||||||
|
const cats = allCategories
|
||||||
|
.filter((cat) => filterCat === 'all' || cat.id === filterCat)
|
||||||
|
.map((cat) => {
|
||||||
|
const catSkills = filteredSkills.filter((s) => s.category_id === cat.id)
|
||||||
|
return {
|
||||||
|
name: cat.name,
|
||||||
|
color: cat.color,
|
||||||
|
children: catSkills.map((s) => {
|
||||||
|
let total = 0
|
||||||
|
let count = 0
|
||||||
|
if (filterMember !== 'all') {
|
||||||
|
const key = `${filterMember}-${s.id}`
|
||||||
|
const lvl = levels[key]?.level
|
||||||
|
if (lvl) { total += lvl; count++ }
|
||||||
|
} else {
|
||||||
|
members.forEach((m) => {
|
||||||
|
const key = `${m.id}-${s.id}`
|
||||||
|
const lvl = levels[key]?.level
|
||||||
|
if (lvl) { total += lvl; count++ }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const avg = count > 0 ? +(total / count).toFixed(1) : 0
|
||||||
|
return {
|
||||||
|
name: s.name,
|
||||||
|
size: 1,
|
||||||
|
avg,
|
||||||
|
count,
|
||||||
|
skillId: s.id,
|
||||||
|
catId: cat.id,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((cat) => cat.children.length > 0)
|
||||||
|
|
||||||
|
const flatData = cats
|
||||||
|
.flatMap((cat) => cat.children)
|
||||||
|
.sort((a, b) => b.avg - a.avg)
|
||||||
|
|
||||||
|
if (flatData.length === 0) {
|
||||||
|
return <div className="p-8 text-center text-gray-400">Aucune donnée à afficher</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
function getColor(avg) {
|
||||||
|
if (avg >= 3.5) return '#34d399'
|
||||||
|
if (avg >= 2.5) return '#fbbf24'
|
||||||
|
if (avg >= 1.5) return '#60a5fa'
|
||||||
|
return '#9ca3af'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="bg-white dark:bg-gray-950 rounded-lg p-4">
|
||||||
|
<ResponsiveContainer width="100%" height={500}>
|
||||||
|
<Treemap
|
||||||
|
data={flatData}
|
||||||
|
dataKey="size"
|
||||||
|
aspectRatio={4 / 3}
|
||||||
|
stroke="var(--background)"
|
||||||
|
fill="#8884d8"
|
||||||
|
content={({ x, y, width, height, payload }) => {
|
||||||
|
if (!payload) return null
|
||||||
|
return (
|
||||||
|
<g>
|
||||||
|
<rect
|
||||||
|
x={x}
|
||||||
|
y={y}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
fill={getColor(payload.avg)}
|
||||||
|
stroke="var(--background)"
|
||||||
|
strokeWidth={2}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
onClick={() => onCellClick?.(payload.catId, filterMember !== 'all' ? filterMember : undefined)}
|
||||||
|
rx={4}
|
||||||
|
/>
|
||||||
|
{width > 40 && height > 30 && (
|
||||||
|
<>
|
||||||
|
<text
|
||||||
|
x={x + width / 2}
|
||||||
|
y={y + height / 2 - 4}
|
||||||
|
textAnchor="middle"
|
||||||
|
fill={payload.avg >= 2.5 ? '#000' : '#fff'}
|
||||||
|
fontSize={12}
|
||||||
|
fontWeight={600}
|
||||||
|
>
|
||||||
|
{payload.name}
|
||||||
|
</text>
|
||||||
|
<text
|
||||||
|
x={x + width / 2}
|
||||||
|
y={y + height / 2 + 12}
|
||||||
|
textAnchor="middle"
|
||||||
|
fill={payload.avg >= 2.5 ? '#000' : '#fff'}
|
||||||
|
fontSize={11}
|
||||||
|
>
|
||||||
|
{payload.avg}
|
||||||
|
{filterMember === 'all' && ` (${payload.count})`}
|
||||||
|
</text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
background: 'var(--background)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius)',
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
formatter={(value, name, props) => {
|
||||||
|
const p = props.payload
|
||||||
|
if (!p) return []
|
||||||
|
return [
|
||||||
|
filterMember === 'all'
|
||||||
|
? `Moyenne : ${p.avg} — ${p.count} évalué(s)`
|
||||||
|
: `Niveau : ${p.avg}`,
|
||||||
|
p.name,
|
||||||
|
]
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Treemap>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{flatData.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-500 dark:text-gray-400 mb-3 uppercase tracking-wider">
|
||||||
|
Détail des compétences
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{flatData.map((s) => (
|
||||||
|
<div
|
||||||
|
key={s.skillId}
|
||||||
|
className="flex items-center justify-between py-1.5 px-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer text-sm"
|
||||||
|
onClick={() => onCellClick?.(s.catId, filterMember !== 'all' ? filterMember : undefined)}
|
||||||
|
>
|
||||||
|
<span>{s.name}</span>
|
||||||
|
<span className="font-medium">{s.avg}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Table2, Palette, ScatterChart, Share2, Grid3x3, Radar, BarChart3,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
const views = [
|
||||||
|
{ id: 'table', icon: Table2, label: 'Tableau' },
|
||||||
|
{ id: 'heat', icon: Palette, label: 'Matrice thermique' },
|
||||||
|
{ id: 'scatter', icon: ScatterChart, label: 'Nuage de points' },
|
||||||
|
{ id: 'graph', icon: Share2, label: 'Graphe' },
|
||||||
|
{ id: 'treemap', icon: Grid3x3, label: 'Treemap' },
|
||||||
|
{ id: 'radar', icon: Radar, label: 'Radar' },
|
||||||
|
{ id: 'bars', icon: BarChart3, label: 'Barres' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function ViewSwitcher({ active, onChange }) {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-1" role="group" aria-label="Mode d'affichage">
|
||||||
|
{views.map((v) => {
|
||||||
|
const Icon = v.icon
|
||||||
|
const isActive = active === v.id
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={v.id}
|
||||||
|
variant={isActive ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={() => onChange(v.id)}
|
||||||
|
title={v.label}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
|
export function CategoryCard({ category, skills, onEdit, onDelete }) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<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: category.color }} />
|
||||||
|
{category.name}
|
||||||
|
<Badge variant="secondary" className="ml-2">{skills.length}</Badge>
|
||||||
|
</CardTitle>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => onEdit(category)}>
|
||||||
|
✎
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => onDelete(category.id)}>
|
||||||
|
🗑
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{skills.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-400 dark:text-gray-500">Aucune compétence dans cette catégorie</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{skills.map((s) => (
|
||||||
|
<Badge key={s.id} variant="outline" className="pr-1">
|
||||||
|
{s.name}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
/* eslint-disable react-refresh/only-export-components */
|
||||||
import { createContext, useContext, useEffect, useState } from 'react'
|
import { createContext, useContext, useEffect, useState } from 'react'
|
||||||
import { supabase } from '@/lib/supabase'
|
import { supabase } from '@/lib/supabase'
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import { supabase } from '@/lib/supabase'
|
||||||
|
|
||||||
|
export function useCategories() {
|
||||||
|
const [categories, setCategories] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const fetch = useCallback(async () => {
|
||||||
|
const { data, error } = await supabase.from('categories').select('*').order('name')
|
||||||
|
if (!error && data) setCategories(data)
|
||||||
|
setLoading(false)
|
||||||
|
return { data, error }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
useEffect(() => { fetch() }, [fetch])
|
||||||
|
|
||||||
|
return { categories, loading, refetch: fetch }
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { useEffect, useState, useCallback, useRef } from 'react'
|
||||||
|
import { supabase } from '@/lib/supabase'
|
||||||
|
|
||||||
|
const PAGE_SIZE = 50
|
||||||
|
|
||||||
|
export function useHistory() {
|
||||||
|
const [history, setHistory] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [count, setCount] = useState(0)
|
||||||
|
const [page, setPage] = useState(0)
|
||||||
|
const [filters, setFilters] = useState({ memberId: 'all', skillId: 'all' })
|
||||||
|
const channelRef = useRef(null)
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(count / PAGE_SIZE)
|
||||||
|
|
||||||
|
const fetch = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
let query = supabase
|
||||||
|
.from('skill_history')
|
||||||
|
.select('*, member:member_id(full_name), skill:skill_id(name), changer:changed_by(full_name)', { count: 'exact' })
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.range(page * PAGE_SIZE, (page + 1) * PAGE_SIZE - 1)
|
||||||
|
|
||||||
|
if (filters.memberId !== 'all') query = query.eq('member_id', filters.memberId)
|
||||||
|
if (filters.skillId !== 'all') query = query.eq('skill_id', filters.skillId)
|
||||||
|
|
||||||
|
const { data, count: total } = await query
|
||||||
|
if (data) setHistory(data)
|
||||||
|
if (total !== null) setCount(total)
|
||||||
|
setLoading(false)
|
||||||
|
}, [page, filters])
|
||||||
|
|
||||||
|
/* eslint-disable react-hooks/set-state-in-effect */
|
||||||
|
useEffect(() => {
|
||||||
|
fetch()
|
||||||
|
|
||||||
|
channelRef.current = supabase
|
||||||
|
.channel('skill_history_changes')
|
||||||
|
.on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'skill_history' }, () => {
|
||||||
|
if (page === 0) fetch()
|
||||||
|
})
|
||||||
|
.subscribe()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
channelRef.current?.unsubscribe()
|
||||||
|
}
|
||||||
|
}, [fetch, page])
|
||||||
|
/* eslint-enable react-hooks/set-state-in-effect */
|
||||||
|
|
||||||
|
function setFilter(key, value) {
|
||||||
|
setFilters((f) => ({ ...f, [key]: value }))
|
||||||
|
setPage(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextPage() {
|
||||||
|
if (page < totalPages - 1) setPage((p) => p + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function prevPage() {
|
||||||
|
if (page > 0) setPage((p) => p - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
history, loading, count, page, totalPages, filters,
|
||||||
|
setFilter, nextPage, prevPage, refetch: fetch,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import { supabase } from '@/lib/supabase'
|
||||||
|
|
||||||
|
export function useMembers() {
|
||||||
|
const [members, setMembers] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const fetch = useCallback(async () => {
|
||||||
|
const { data, error } = await supabase.from('members').select('*').order('full_name')
|
||||||
|
if (!error && data) setMembers(data)
|
||||||
|
setLoading(false)
|
||||||
|
return { data, error }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
useEffect(() => { fetch() }, [fetch])
|
||||||
|
|
||||||
|
return { members, loading, refetch: fetch }
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { useEffect, useState, useCallback, useRef } from 'react'
|
||||||
|
import { supabase } from '@/lib/supabase'
|
||||||
|
|
||||||
|
export function useSkillLevels() {
|
||||||
|
const [levels, setLevels] = useState({})
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const channelRef = useRef(null)
|
||||||
|
|
||||||
|
const fetch = useCallback(async () => {
|
||||||
|
const { data, error } = await supabase.from('skill_levels').select('*')
|
||||||
|
if (!error && data) {
|
||||||
|
const map = {}
|
||||||
|
data.forEach((l) => { map[`${l.member_id}-${l.skill_id}`] = l })
|
||||||
|
setLevels(map)
|
||||||
|
}
|
||||||
|
setLoading(false)
|
||||||
|
return { data, error }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
/* eslint-disable react-hooks/set-state-in-effect */
|
||||||
|
useEffect(() => {
|
||||||
|
fetch()
|
||||||
|
|
||||||
|
channelRef.current = supabase
|
||||||
|
.channel('skill_levels_changes')
|
||||||
|
.on('postgres_changes', { event: '*', schema: 'public', table: 'skill_levels' }, () => {
|
||||||
|
fetch()
|
||||||
|
})
|
||||||
|
.subscribe()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
channelRef.current?.unsubscribe()
|
||||||
|
}
|
||||||
|
}, [fetch])
|
||||||
|
/* eslint-enable react-hooks/set-state-in-effect */
|
||||||
|
|
||||||
|
async function updateLevel(memberId, skillId, newLevel, changedBy) {
|
||||||
|
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 })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (oldLevel !== newLevel && changedBy) {
|
||||||
|
await supabase.from('skill_history').insert({
|
||||||
|
member_id: memberId,
|
||||||
|
skill_id: skillId,
|
||||||
|
old_level: oldLevel || null,
|
||||||
|
new_level: newLevel,
|
||||||
|
changed_by: changedBy,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAverageSkillRating(skillId) {
|
||||||
|
const { data } = await supabase
|
||||||
|
.from('skill_levels')
|
||||||
|
.select('level')
|
||||||
|
.eq('skill_id', skillId)
|
||||||
|
if (!data || data.length === 0) return null
|
||||||
|
return (data.reduce((sum, l) => sum + l.level, 0) / data.length).toFixed(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { levels, loading, refetch: fetch, updateLevel, getAverageSkillRating }
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import { supabase } from '@/lib/supabase'
|
||||||
|
|
||||||
|
export function useSkills() {
|
||||||
|
const [skills, setSkills] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const fetch = useCallback(async () => {
|
||||||
|
const { data, error } = await supabase.from('skills').select('*, category:category_id(name)').order('name')
|
||||||
|
if (!error && data) setSkills(data)
|
||||||
|
setLoading(false)
|
||||||
|
return { data, error }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
useEffect(() => { fetch() }, [fetch])
|
||||||
|
|
||||||
|
return { skills, loading, refetch: fetch }
|
||||||
|
}
|
||||||
+4
-1
@@ -1,10 +1,13 @@
|
|||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import { ThemeProvider } from 'next-themes'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App.jsx'
|
import App from './App.jsx'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')).render(
|
createRoot(document.getElementById('root')).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||||
|
<App />
|
||||||
|
</ThemeProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,29 +16,36 @@ export function AcceptInvite() {
|
|||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [valid, setValid] = useState(false)
|
const [valid, setValid] = useState(false)
|
||||||
|
|
||||||
|
/* eslint-disable react-hooks/set-state-in-effect */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
async function checkToken() {
|
async function checkToken() {
|
||||||
const { data, error } = await supabase
|
const { data } = await supabase
|
||||||
.from('invitations')
|
.from('invitations')
|
||||||
.select('*')
|
.select('*')
|
||||||
.eq('token', token)
|
.eq('token', token)
|
||||||
.eq('accepted', false)
|
.eq('accepted', false)
|
||||||
|
.gte('expires_at', new Date().toISOString())
|
||||||
.single()
|
.single()
|
||||||
if (data && !error) {
|
if (!cancelled) {
|
||||||
setEmail(data.email)
|
if (data) {
|
||||||
setValid(true)
|
setEmail(data.email)
|
||||||
|
setValid(true)
|
||||||
|
}
|
||||||
|
setLoading(false)
|
||||||
}
|
}
|
||||||
setLoading(false)
|
|
||||||
}
|
}
|
||||||
if (token) checkToken()
|
if (token) checkToken()
|
||||||
else setLoading(false)
|
else setLoading(false)
|
||||||
|
return () => { cancelled = true }
|
||||||
}, [token])
|
}, [token])
|
||||||
|
/* eslint-enable react-hooks/set-state-in-effect */
|
||||||
|
|
||||||
async function handleSubmit(e) {
|
async function handleSubmit(e) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
|
||||||
const { data, error } = await supabase.auth.signUp({
|
const { error } = await supabase.auth.signUp({
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
options: { data: { full_name: name } },
|
options: { data: { full_name: name } },
|
||||||
@@ -50,7 +57,6 @@ export function AcceptInvite() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Marquer l'invitation comme acceptée
|
|
||||||
await supabase.from('invitations').update({ accepted: true }).eq('token', token)
|
await supabase.from('invitations').update({ accepted: true }).eq('token', token)
|
||||||
|
|
||||||
toast.success('Compte créé ! Vous pouvez vous connecter.')
|
toast.success('Compte créé ! Vous pouvez vous connecter.')
|
||||||
@@ -69,11 +75,11 @@ export function AcceptInvite() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
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">
|
<Card className="w-full max-w-md">
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-2xl text-center">Accepter l'invitation</CardTitle>
|
<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>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ export function Dashboard() {
|
|||||||
})
|
})
|
||||||
setRecentChanges(history.data || [])
|
setRecentChanges(history.data || [])
|
||||||
|
|
||||||
// Compétences les mieux notées (moyenne)
|
|
||||||
const { data: levels } = await supabase
|
const { data: levels } = await supabase
|
||||||
.from('skill_levels')
|
.from('skill_levels')
|
||||||
.select('skill_id, level, skill:skill_id(name)')
|
.select('skill_id, level, skill:skill_id(name)')
|
||||||
@@ -54,7 +53,7 @@ export function Dashboard() {
|
|||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<h1 className="text-2xl font-bold">Tableau de bord</h1>
|
<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>
|
<Card>
|
||||||
<CardHeader><CardTitle className="text-lg">Membres</CardTitle></CardHeader>
|
<CardHeader><CardTitle className="text-lg">Membres</CardTitle></CardHeader>
|
||||||
<CardContent><p className="text-3xl font-bold">{stats.members}</p></CardContent>
|
<CardContent><p className="text-3xl font-bold">{stats.members}</p></CardContent>
|
||||||
@@ -69,12 +68,12 @@ export function Dashboard() {
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader><CardTitle className="text-lg">Compétences les mieux notées</CardTitle></CardHeader>
|
<CardHeader><CardTitle className="text-lg">Compétences les mieux notées</CardTitle></CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{topSkills.length === 0 ? (
|
{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">
|
<ul className="space-y-2">
|
||||||
{topSkills.map((s) => (
|
{topSkills.map((s) => (
|
||||||
@@ -92,11 +91,11 @@ export function Dashboard() {
|
|||||||
<CardHeader><CardTitle className="text-lg">Dernières évolutions</CardTitle></CardHeader>
|
<CardHeader><CardTitle className="text-lg">Dernières évolutions</CardTitle></CardHeader>
|
||||||
<CardContent className="max-h-80 overflow-auto">
|
<CardContent className="max-h-80 overflow-auto">
|
||||||
{recentChanges.length === 0 ? (
|
{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">
|
<ul className="space-y-3">
|
||||||
{recentChanges.map((c) => (
|
{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>
|
<span className="font-medium">{c.member?.full_name}</span>
|
||||||
{' '}a mis à jour{' '}
|
{' '}a mis à jour{' '}
|
||||||
<span className="font-medium">{c.skill?.name}</span>
|
<span className="font-medium">{c.skill?.name}</span>
|
||||||
|
|||||||
+40
-49
@@ -1,44 +1,15 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useMembers } from '@/hooks/useMembers'
|
||||||
import { supabase } from '@/lib/supabase'
|
import { useSkills } from '@/hooks/useSkills'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { useHistory } from '@/hooks/useHistory'
|
||||||
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
import { SkillLevelBadge } from '@/components/SkillLevelBadge'
|
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() {
|
export function History() {
|
||||||
const [history, setHistory] = useState([])
|
const { members } = useMembers()
|
||||||
const [members, setMembers] = useState([])
|
const { skills } = useSkills()
|
||||||
const [skills, setSkills] = useState([])
|
const { history, loading, count, page, totalPages, filters, setFilter, nextPage, prevPage } = useHistory()
|
||||||
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])
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -46,9 +17,9 @@ export function History() {
|
|||||||
|
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<select
|
<select
|
||||||
className="border rounded px-2 py-1 text-sm"
|
className="border rounded px-2 py-1 text-sm dark:bg-gray-800 dark:border-gray-700"
|
||||||
value={filterMember}
|
value={filters.memberId}
|
||||||
onChange={(e) => setFilterMember(e.target.value)}
|
onChange={(e) => setFilter('memberId', e.target.value)}
|
||||||
>
|
>
|
||||||
<option value="all">Tous les membres</option>
|
<option value="all">Tous les membres</option>
|
||||||
{members.map((m) => (
|
{members.map((m) => (
|
||||||
@@ -56,9 +27,9 @@ export function History() {
|
|||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
<select
|
<select
|
||||||
className="border rounded px-2 py-1 text-sm"
|
className="border rounded px-2 py-1 text-sm dark:bg-gray-800 dark:border-gray-700"
|
||||||
value={filterSkill}
|
value={filters.skillId}
|
||||||
onChange={(e) => setFilterSkill(e.target.value)}
|
onChange={(e) => setFilter('skillId', e.target.value)}
|
||||||
>
|
>
|
||||||
<option value="all">Toutes les compétences</option>
|
<option value="all">Toutes les compétences</option>
|
||||||
{skills.map((s) => (
|
{skills.map((s) => (
|
||||||
@@ -69,18 +40,24 @@ export function History() {
|
|||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardContent className="p-0">
|
<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>
|
<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) => (
|
{history.map((h) => (
|
||||||
<li key={h.id} className="p-4 flex items-center justify-between">
|
<li key={h.id} className="p-4 flex items-center justify-between">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="text-sm">
|
<p className="text-sm">
|
||||||
<span className="font-medium">{h.member?.full_name}</span>
|
<span className="text-gray-500 dark:text-gray-400">Membre :</span>
|
||||||
{' → '}<span className="font-medium">{h.skill?.name}</span>
|
{' '}<span className="font-medium">{h.member?.full_name || h.member_id?.slice(0, 8)}</span>
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-gray-500">
|
<p className="text-sm">
|
||||||
|
<span className="text-gray-500 dark:text-gray-400">Compétence :</span>
|
||||||
|
{' '}<span className="font-medium">{h.skill?.name}</span>
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||||
Par {h.changer?.full_name} — {new Date(h.created_at).toLocaleString()}
|
Par {h.changer?.full_name} — {new Date(h.created_at).toLocaleString()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -95,6 +72,20 @@ export function History() {
|
|||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-2
@@ -4,10 +4,11 @@ import { useAuth } from '@/context/AuthContext'
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
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 { Badge } from '@/components/ui/badge'
|
||||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||||
import { InviteUserModal } from '@/components/InviteUserModal'
|
import { InviteUserModal } from '@/components/InviteUserModal'
|
||||||
|
import { Download } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
export function Members() {
|
export function Members() {
|
||||||
@@ -37,11 +38,32 @@ export function Members() {
|
|||||||
toast.success('Membre supprimé')
|
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 (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-2xl font-bold">Membres</h1>
|
<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>
|
</div>
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
|
|||||||
@@ -27,15 +27,15 @@ export function Profile() {
|
|||||||
<CardHeader><CardTitle>Informations</CardTitle></CardHeader>
|
<CardHeader><CardTitle>Informations</CardTitle></CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div>
|
<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>
|
<p className="font-medium">{profile?.email}</p>
|
||||||
</div>
|
</div>
|
||||||
<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>
|
<p className="font-medium capitalize">{profile?.role}</p>
|
||||||
</div>
|
</div>
|
||||||
<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)} />
|
<Input value={name} onChange={(e) => setName(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<Button onClick={handleSave}>Enregistrer</Button>
|
<Button onClick={handleSave}>Enregistrer</Button>
|
||||||
|
|||||||
+215
-162
@@ -1,42 +1,53 @@
|
|||||||
import { useEffect, useState, useCallback } from 'react'
|
import { lazy, Suspense, useState } from 'react'
|
||||||
import { supabase } from '@/lib/supabase'
|
import { useSearchParams } from 'react-router-dom'
|
||||||
import { useAuth } from '@/context/AuthContext'
|
import { useAuth } from '@/context/AuthContext'
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
import { useCategories } from '@/hooks/useCategories'
|
||||||
import { SkillLevelBadge, SkillLevelSelect } from '@/components/SkillLevelBadge'
|
import { useSkills } from '@/hooks/useSkills'
|
||||||
import { Input } from '@/components/ui/input'
|
import { useMembers } from '@/hooks/useMembers'
|
||||||
|
import { useSkillLevels } from '@/hooks/useSkillLevels'
|
||||||
|
import { SkillMatrixFilters } from '@/components/matrix/SkillMatrixFilters'
|
||||||
|
import { SkillMatrixTable } from '@/components/matrix/SkillMatrixTable'
|
||||||
|
import { SkillMemberForm } from '@/components/matrix/SkillMemberForm'
|
||||||
|
import { ViewSwitcher } from '@/components/matrix/ViewSwitcher'
|
||||||
|
import { HeatmapView } from '@/components/matrix/HeatmapView'
|
||||||
|
import { Download } from 'lucide-react'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
const ScatterView = lazy(() => import('@/components/matrix/ScatterView'))
|
||||||
|
const GraphView = lazy(() => import('@/components/matrix/GraphView'))
|
||||||
|
const TreemapView = lazy(() => import('@/components/matrix/TreemapView'))
|
||||||
|
const RadarView = lazy(() => import('@/components/matrix/RadarView'))
|
||||||
|
const BarChartView = lazy(() => import('@/components/matrix/BarChartView'))
|
||||||
|
|
||||||
|
const hideMemberViews = new Set(['bars'])
|
||||||
|
const showLevelLegendViews = new Set(['table', 'heat'])
|
||||||
|
|
||||||
export function SkillMatrix() {
|
export function SkillMatrix() {
|
||||||
const { profile } = useAuth()
|
const { profile } = useAuth()
|
||||||
const isAdmin = profile?.role === 'admin'
|
const isAdmin = profile?.role === 'admin'
|
||||||
const [categories, setCategories] = useState([])
|
const currentUserId = profile?.id
|
||||||
const [skills, setSkills] = useState([])
|
const { categories } = useCategories()
|
||||||
const [members, setMembers] = useState([])
|
const { skills } = useSkills()
|
||||||
const [levels, setLevels] = useState({})
|
const { members } = useMembers()
|
||||||
|
const { levels, updateLevel } = useSkillLevels()
|
||||||
|
|
||||||
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
|
const viewMode = searchParams.get('view') || 'table'
|
||||||
|
|
||||||
const [filterCat, setFilterCat] = useState('all')
|
const [filterCat, setFilterCat] = useState('all')
|
||||||
const [filterMember, setFilterMember] = useState('all')
|
const [filterMember, setFilterMember] = useState('all')
|
||||||
const [filterMinLevel, setFilterMinLevel] = useState(0)
|
const [filterMinLevel, setFilterMinLevel] = useState(0)
|
||||||
const [editing, setEditing] = useState(null)
|
const [editing, setEditing] = useState(null)
|
||||||
|
|
||||||
useEffect(() => { load() }, [])
|
function setViewMode(mode) {
|
||||||
|
setSearchParams(mode === 'table' ? {} : { view: mode }, { replace: true })
|
||||||
|
}
|
||||||
|
|
||||||
async function load() {
|
function onFilterChange(key, value) {
|
||||||
const [catRes, skillRes, memberRes, levelRes] = await Promise.all([
|
if (key === 'cat') setFilterCat(value)
|
||||||
supabase.from('categories').select('*').order('name'),
|
if (key === 'member') setFilterMember(value)
|
||||||
supabase.from('skills').select('*').order('name'),
|
if (key === 'minLevel') setFilterMinLevel(value)
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const filteredSkills = filterCat === 'all'
|
const filteredSkills = filterCat === 'all'
|
||||||
@@ -47,153 +58,195 @@ export function SkillMatrix() {
|
|||||||
? members
|
? members
|
||||||
: members.filter((m) => m.id === filterMember)
|
: members.filter((m) => m.id === filterMember)
|
||||||
|
|
||||||
const filteredByLevel = filteredMembers.filter((m) => {
|
const visibleMembers = filterMinLevel === 0
|
||||||
if (filterMinLevel === 0) return true
|
? filteredMembers
|
||||||
return filteredSkills.some((s) => {
|
: filteredMembers.filter((m) =>
|
||||||
const key = `${m.id}-${s.id}`
|
filteredSkills.some((s) => {
|
||||||
return (levels[key]?.level || 0) >= filterMinLevel
|
const key = `${m.id}-${s.id}`
|
||||||
})
|
return (levels[key]?.level || 0) >= filterMinLevel
|
||||||
})
|
})
|
||||||
|
)
|
||||||
|
|
||||||
async function updateLevel(memberId, skillId, newLevel) {
|
async function handleUpdate(memberId, skillId, newLevel) {
|
||||||
const key = `${memberId}-${skillId}`
|
await updateLevel(memberId, skillId, newLevel, profile.id)
|
||||||
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()
|
|
||||||
setEditing(null)
|
setEditing(null)
|
||||||
toast.success('Niveau mis à jour')
|
toast.success('Niveau mis à jour')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function exportCSV() {
|
||||||
|
const header = ['Compétence', ...visibleMembers.map((m) => m.full_name || m.email)].join(',')
|
||||||
|
const rows = filteredSkills.map((s) => {
|
||||||
|
const levelsRow = visibleMembers.map((m) => {
|
||||||
|
const key = `${m.id}-${s.id}`
|
||||||
|
return levels[key]?.level || ''
|
||||||
|
})
|
||||||
|
return [`"${s.name}"`, ...levelsRow].join(',')
|
||||||
|
})
|
||||||
|
const blob = new Blob(['\uFEFF' + header + '\n' + rows.join('\n')], { type: 'text/csv;charset=utf-8;' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = 'matrice-competences.csv'
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
toast.success('Fichier CSV téléchargé')
|
||||||
|
}
|
||||||
|
|
||||||
|
function navigateToView(mode, filters) {
|
||||||
|
if (filters.cat !== undefined) setFilterCat(filters.cat)
|
||||||
|
if (filters.member !== undefined) setFilterMember(filters.member)
|
||||||
|
if (filters.minLevel !== undefined) setFilterMinLevel(filters.minLevel)
|
||||||
|
setViewMode(mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
const commonProps = {
|
||||||
|
categories, skills, members, levels,
|
||||||
|
isAdmin, currentUserId,
|
||||||
|
filterCat, filterMember, filterMinLevel,
|
||||||
|
}
|
||||||
|
|
||||||
|
const editableProps = {
|
||||||
|
editing, onEdit: setEditing, onUpdate: handleUpdate, onCancel: () => setEditing(null),
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderView() {
|
||||||
|
switch (viewMode) {
|
||||||
|
case 'heat':
|
||||||
|
return (
|
||||||
|
<HeatmapView
|
||||||
|
{...commonProps}
|
||||||
|
{...editableProps}
|
||||||
|
filteredSkills={filteredSkills}
|
||||||
|
visibleMembers={visibleMembers}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'scatter':
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<Fallback />}>
|
||||||
|
<ScatterView
|
||||||
|
{...commonProps}
|
||||||
|
filteredSkills={filteredSkills}
|
||||||
|
onMemberSelect={(memberId) => navigateToView('table', { member: memberId })}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'graph':
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<Fallback />}>
|
||||||
|
<GraphView
|
||||||
|
{...commonProps}
|
||||||
|
filteredSkills={filteredSkills}
|
||||||
|
filteredMembers={filteredMembers}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'treemap':
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<Fallback />}>
|
||||||
|
<TreemapView
|
||||||
|
{...commonProps}
|
||||||
|
filteredSkills={filteredSkills}
|
||||||
|
onCellClick={(catId, memberId) => navigateToView('table', { cat: catId, member: memberId })}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'radar':
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<Fallback />}>
|
||||||
|
<RadarView {...commonProps} />
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
|
||||||
|
case 'bars':
|
||||||
|
return (
|
||||||
|
<Suspense fallback={<Fallback />}>
|
||||||
|
<BarChartView
|
||||||
|
{...commonProps}
|
||||||
|
onBarClick={(catId, level) => navigateToView('table', { cat: catId, minLevel: level })}
|
||||||
|
/>
|
||||||
|
</Suspense>
|
||||||
|
)
|
||||||
|
|
||||||
|
default:
|
||||||
|
if (filterMember !== 'all' && visibleMembers.length === 1) {
|
||||||
|
return (
|
||||||
|
<SkillMemberForm
|
||||||
|
member={visibleMembers[0]}
|
||||||
|
categories={categories}
|
||||||
|
skills={filteredSkills}
|
||||||
|
levels={levels}
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
currentUserId={currentUserId}
|
||||||
|
onSave={(memberId, changes) => {
|
||||||
|
Promise.all(changes.map((c) => updateLevel(memberId, c.skillId, c.newLevel, profile.id)))
|
||||||
|
.then(() => toast.success('Compétences mises à jour'))
|
||||||
|
.catch(() => toast.error('Erreur lors de la mise à jour'))
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<SkillMatrixTable
|
||||||
|
categories={categories}
|
||||||
|
skills={filteredSkills}
|
||||||
|
members={visibleMembers}
|
||||||
|
levels={levels}
|
||||||
|
isAdmin={isAdmin}
|
||||||
|
currentUserId={currentUserId}
|
||||||
|
editing={editing}
|
||||||
|
onEdit={setEditing}
|
||||||
|
onUpdate={handleUpdate}
|
||||||
|
onCancel={() => setEditing(null)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<h1 className="text-2xl font-bold">Matrice des compétences</h1>
|
<div className="flex items-center justify-between flex-wrap gap-4">
|
||||||
|
<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">
|
<div className="flex items-center gap-2">
|
||||||
<label className="text-sm text-gray-600">Catégorie :</label>
|
<ViewSwitcher active={viewMode} onChange={setViewMode} />
|
||||||
<select
|
<Button variant="outline" onClick={exportCSV}>
|
||||||
className="border rounded px-2 py-1 text-sm"
|
<Download className="h-4 w-4 mr-2" />
|
||||||
value={filterCat}
|
CSV
|
||||||
onChange={(e) => setFilterCat(e.target.value)}
|
</Button>
|
||||||
>
|
|
||||||
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-auto">
|
<SkillMatrixFilters
|
||||||
<table className="w-full border-collapse">
|
categories={categories}
|
||||||
<thead>
|
members={members}
|
||||||
<tr>
|
filterCat={filterCat}
|
||||||
<th className="text-left p-2 bg-gray-100 border sticky left-0 z-10 min-w-[180px]">Membre</th>
|
filterMember={filterMember}
|
||||||
{filteredSkills.map((s) => (
|
filterMinLevel={filterMinLevel}
|
||||||
<th key={s.id} className="p-2 bg-gray-100 border text-sm text-center min-w-[120px]">{s.name}</th>
|
onFilterChange={onFilterChange}
|
||||||
))}
|
hideMember={hideMemberViews.has(viewMode)}
|
||||||
</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>
|
|
||||||
|
|
||||||
<div className="flex gap-4 text-sm text-gray-500">
|
{showLevelLegendViews.has(viewMode) && (
|
||||||
<span><span className="inline-block w-3 h-3 rounded-full bg-gray-200 mr-1" /> Débutant</span>
|
<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-blue-200 mr-1" /> Intermédiaire</span>
|
<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-amber-200 mr-1" /> Avancé</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-green-200 mr-1" /> Expert</span>
|
<span><span className="inline-block w-3 h-3 rounded-full bg-amber-200 mr-1" /> Avancé</span>
|
||||||
</div>
|
<span><span className="inline-block w-3 h-3 rounded-full bg-green-200 mr-1" /> Expert</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{renderView()}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Fallback() {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-[400px] text-gray-400">
|
||||||
|
Chargement...
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+36
-57
@@ -1,15 +1,18 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { supabase } from '@/lib/supabase'
|
import { supabase } from '@/lib/supabase'
|
||||||
|
import { useCategories } from '@/hooks/useCategories'
|
||||||
|
import { useSkills } from '@/hooks/useSkills'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
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 { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||||
|
import { CategoryCard } from '@/components/skills/CategoryCard'
|
||||||
|
import { Search } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
export function Skills() {
|
export function Skills() {
|
||||||
const [categories, setCategories] = useState([])
|
const { categories, refetch: refetchCats } = useCategories()
|
||||||
const [skills, setSkills] = useState([])
|
const { skills, refetch: refetchSkills } = useSkills()
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
const [newCatName, setNewCatName] = useState('')
|
const [newCatName, setNewCatName] = useState('')
|
||||||
const [newCatColor, setNewCatColor] = useState('#3b82f6')
|
const [newCatColor, setNewCatColor] = useState('#3b82f6')
|
||||||
const [editCat, setEditCat] = useState(null)
|
const [editCat, setEditCat] = useState(null)
|
||||||
@@ -17,17 +20,6 @@ export function Skills() {
|
|||||||
const [catDialogOpen, setCatDialogOpen] = useState(false)
|
const [catDialogOpen, setCatDialogOpen] = useState(false)
|
||||||
const [skillDialogOpen, setSkillDialogOpen] = 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() {
|
async function saveCategory() {
|
||||||
if (editCat) {
|
if (editCat) {
|
||||||
await supabase.from('categories').update({ name: newCatName, color: newCatColor }).eq('id', editCat)
|
await supabase.from('categories').update({ name: newCatName, color: newCatColor }).eq('id', editCat)
|
||||||
@@ -37,28 +29,29 @@ export function Skills() {
|
|||||||
setCatDialogOpen(false)
|
setCatDialogOpen(false)
|
||||||
setEditCat(null)
|
setEditCat(null)
|
||||||
setNewCatName('')
|
setNewCatName('')
|
||||||
load()
|
refetchCats()
|
||||||
toast.success('Catégorie enregistrée')
|
toast.success('Catégorie enregistrée')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteCategory(id) {
|
async function deleteCategory(id) {
|
||||||
const { error } = await supabase.from('categories').delete().eq('id', id)
|
const { error } = await supabase.from('categories').delete().eq('id', id)
|
||||||
if (error) toast.error(error.message)
|
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() {
|
async function saveSkill() {
|
||||||
await supabase.from('skills').insert({ name: newSkill.name, category_id: newSkill.category_id })
|
await supabase.from('skills').insert({ name: newSkill.name, category_id: newSkill.category_id })
|
||||||
setSkillDialogOpen(false)
|
setSkillDialogOpen(false)
|
||||||
setNewSkill({ name: '', category_id: '' })
|
setNewSkill({ name: '', category_id: '' })
|
||||||
load()
|
refetchSkills()
|
||||||
toast.success('Compétence ajoutée')
|
toast.success('Compétence ajoutée')
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteSkill(id) {
|
function openEditCategory(cat) {
|
||||||
await supabase.from('skills').delete().eq('id', id)
|
setEditCat(cat.id)
|
||||||
load()
|
setNewCatName(cat.name)
|
||||||
toast.success('Compétence supprimée')
|
setNewCatColor(cat.color)
|
||||||
|
setCatDialogOpen(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -73,7 +66,7 @@ export function Skills() {
|
|||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<Input placeholder="Nom" value={newSkill.name} onChange={(e) => setNewSkill({ ...newSkill, name: e.target.value })} />
|
<Input placeholder="Nom" value={newSkill.name} onChange={(e) => setNewSkill({ ...newSkill, name: e.target.value })} />
|
||||||
<select
|
<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}
|
value={newSkill.category_id}
|
||||||
onChange={(e) => setNewSkill({ ...newSkill, category_id: e.target.value })}
|
onChange={(e) => setNewSkill({ ...newSkill, category_id: e.target.value })}
|
||||||
>
|
>
|
||||||
@@ -99,41 +92,27 @@ export function Skills() {
|
|||||||
</div>
|
</div>
|
||||||
</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) => {
|
{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 (
|
return (
|
||||||
<Card key={cat.id}>
|
<CategoryCard
|
||||||
<CardHeader className="flex flex-row items-center justify-between py-3">
|
key={cat.id}
|
||||||
<CardTitle className="text-lg flex items-center gap-2">
|
category={cat}
|
||||||
<span className="w-3 h-3 rounded-full" style={{ backgroundColor: cat.color }} />
|
skills={catSkills}
|
||||||
{cat.name}
|
onEdit={openEditCategory}
|
||||||
<Badge variant="secondary" className="ml-2">{catSkills.length}</Badge>
|
onDelete={deleteCategory}
|
||||||
</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>
|
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user