Compare commits

...
21 Commits
Author SHA1 Message Date
tophe 61e9135070 Ajout pre-commit hook anti-secrets et renforcement .gitignore 2026-05-27 15:24:20 +02:00
tophe 161b6828e3 Retait des .env inutiles 2026-05-27 11:52:26 +02:00
tophe f68ddc6dc6 Add visible error handler for debugging blank page 2026-05-27 01:44:37 +02:00
tophe 57285aeb17 Ajout de 2 .env 2026-05-27 01:44:37 +02:00
tophe 339bdc2280 Trigger Vercel redeploy 2026-05-27 01:44:37 +02:00
tophe a5e1e3e206 Add vercel.json for SPA routing 2026-05-27 01:44:37 +02:00
tophe d1a92299ec Delete .agents/skills directory
Pas besoin de publier...
2026-05-27 01:44:37 +02:00
tophe f1c39eaae2 Maj du titre de la page index 2026-05-26 22:30:12 +02:00
tophe a52b4baddc Merge branch 'master' into main 2026-05-26 18:47:09 +02:00
tophe dc9fd58f66 Ajout de nv fichier de migration SQL 2026-05-26 01:12:28 +02:00
tophe 65a6c50fec Optimisation DB et Migration 2026-05-26 01:11:11 +02:00
tophe e6d2eee9ea Ajout de visuels sur la matrice pour sélection 2026-05-25 20:55:58 +02:00
tophe 24e08fca4f Ajout du composant Form Skill Member 2026-05-25 01:36:29 +02:00
tophe ac1b35b1d9 Ajout de visualisations pratiques de la matrice 2026-05-25 01:34:18 +02:00
tophe 24afa9a8e8 Ajout gitignore pour les opencode stuff 2026-05-24 21:05:03 +02:00
tophe 2ee7decbfd Remaster avec skill supabase only et grillme 2026-05-24 20:30:45 +02:00
tophe e38a3df248 Fix: Add .env to .gitignore and create .env.example
- Uncomment .env in .gitignore to prevent accidental commits
- Remove .env from git tracking
- Add .env.example with placeholder values for documentation
- Fixes security issue where .env was committed with credentials
2026-05-23 17:29:19 +02:00
tophe 6da60a0d81 Ajout de .env 2026-05-22 17:12:03 +02:00
tophe b6d0913b38 2eme commit 2026-05-22 16:36:26 +02:00
tophe 3e08535b5c 2eme commit 2026-05-22 15:25:45 +02:00
tophe a3b331ada6 Initial commit: application de gestion des competences 2026-05-18 00:01:11 +02:00
79 changed files with 41006 additions and 2 deletions
+8
View File
@@ -0,0 +1,8 @@
node_modules
dist
.git
*.local
.env
.env.local
.agents
.dockerignore
+12
View File
@@ -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
+81
View File
@@ -0,0 +1,81 @@
#!/bin/sh
RED='\033[0;31m'
NC='\033[0m'
detected=false
check_file() {
while IFS=: read -r file score; do
[ -z "$file" ] && continue
detected=true
done <<EOF
$(git diff --cached --name-only | while read -r f; do
[ ! -f "$f" ] && continue
case "$f" in
.env|.env.*)
[ "$f" = ".env.example" ] && continue
echo "$f|1"
;;
esac
case "$(basename "$f")" in
.env|.env.*)
[ "$f" = ".env.example" ] && continue
echo "$f|1"
;;
esac
done)
EOF
}
check_diff() {
content=$(git diff --cached --diff-filter=ACM -- "$@" 2>/dev/null)
[ -z "$content" ] && return
patterns='VITE_SUPABASE_SERVICE_ROLE_KEY|SUPABASE_SERVICE_ROLE|eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9|ghp_[0-9a-zA-Z]{36}|gho_[0-9a-zA-Z]{36}|sk_live_|sk_test_|AKIA[0-9A-Z]{16}|-----BEGIN[ A-Z]*PRIVATE KEY-----'
echo "$content" | while read -r line; do
case "$line" in
*VITE_SUPABASE_SERVICE_ROLE_KEY*)
printf "${RED}⛔ Secret détecté : VITE_SUPABASE_SERVICE_ROLE_KEY (clé admin Supabase)${NC}\n"
return 1
;;
*eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9*)
printf "${RED}⛔ Secret détecté : JWT token (eyJ...) dans le diff${NC}\n"
return 1
;;
*ghp_*|*gho_*)
printf "${RED}⛔ Secret détecté : GitHub token (ghp_/gho_)${NC}\n"
return 1
;;
*sk_live_*|*sk_test_*)
printf "${RED}⛔ Secret détecté : clé Stripe${NC}\n"
return 1
;;
*AKIA[0-9A-Z]*)
printf "${RED}⛔ Secret détecté : clé AWS (AKIA)${NC}\n"
return 1
;;
*-----BEGIN*PRIVATE*KEY*-----*)
printf "${RED}⛔ Secret détecté : clé privée RSA/EC${NC}\n"
return 1
;;
esac
done
}
check_file
for f in $(git diff --cached --name-only); do
case "$f" in .githooks/*) continue ;; esac
check_diff "$f"
[ $? -eq 1 ] && detected=true
done
if [ "$detected" = true ]; then
printf "${RED}⛔ Commit bloqué : secret(s) détecté(s) dans les fichiers indexés.${NC}\n"
printf " Vérifie le contenu et utilise 'git rm --cached' si nécessaire.\n"
exit 1
fi
exit 0
+34
View File
@@ -0,0 +1,34 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# Environment
.env
.env.local
.env.*
!.env.example
# OpenCode Stuff
.agents/*
opencode*
+82
View File
@@ -0,0 +1,82 @@
# AGENTS.md — GestionDesCompetences
## Project
React 19 SPA (skills management) built with Vite 8, JavaScript (not TypeScript), Tailwind CSS v4, shadcn/ui, Supabase backend, React Router v7.
## Commands
```
npm run dev # Vite dev server with HMR
npm run build # Production build → dist/
npm run lint # ESLint (flat config)
npm run preview # Serve dist/ locally
npm run test # Vitest run (hooks tests)
npm run test:watch# Vitest watch mode
```
No typecheck script.
## Architecture
- **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)
- **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
- **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
- **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
- DB schema + RLS policies in `supabase/migrations/001_init.sql`
- Tables: `categories`, `skills`, `members`, `level_descriptions`, `skill_levels`, `skill_history`, `invitations`
- `handle_new_user()` trigger auto-creates a `members` row on auth signup
- RLS: read-all for most tables, write restricted to admin role
- 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
Multi-stage: `node:20-alpine` build → `nginx:alpine` serve.
Requires `VITE_SUPABASE_URL` and `VITE_SUPABASE_ANON_KEY` as build args.
SPA routing handled by nginx `try_files`.
## shadcn/ui
Config in `components.json`. Add components with:
```
npx shadcn@latest add <component>
```
Components land in `src/components/ui/`. Uses Lucide icons.
## Conventions
- JavaScript only (`.jsx`), no TypeScript
- ESLint flat config (`eslint.config.js`) — ignores `dist/`
- Tailwind v4 via `@tailwindcss/vite` plugin (no `tailwind.config.js`)
- 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
+20
View File
@@ -0,0 +1,20 @@
FROM node:20-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
ARG VITE_SUPABASE_URL
ARG VITE_SUPABASE_ANON_KEY
ENV VITE_SUPABASE_URL=$VITE_SUPABASE_URL
ENV VITE_SUPABASE_ANON_KEY=$VITE_SUPABASE_ANON_KEY
RUN npm run build
FROM nginx:alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]
+15 -2
View File
@@ -1,3 +1,16 @@
# gestiondescompetences # Gestion Des Compétences
Gestion Des Compétences Cette application permet la gestion et le suivi des compétences. Elle est construite avec React et Vite.
## Développement
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
View File
+25839
View File
File diff suppressed because one or more lines are too long
+25
View File
@@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "radix-nova",
"rsc": false,
"tsx": false,
"tailwind": {
"config": "",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}
+32
View File
@@ -0,0 +1,32 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{js,jsx}'],
extends: [
js.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
globals: globals.browser,
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' },
},
])
+34
View File
@@ -0,0 +1,34 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>gestiondescompetences</title>
</head>
<body>
<div id="root"></div>
<div id="error-root" style="display:none;padding:2rem;font-family:sans-serif;max-width:600px;margin:auto;margin-top:10vh">
<h1 style="color:#dc2626;font-size:1.5rem;margin-bottom:1rem">Erreur de chargement</h1>
<pre id="error-message" style="background:#fef2f2;padding:1rem;border-radius:0.5rem;white-space:pre-wrap;color:#991b1b"></pre>
<button onclick="window.location.reload()" style="margin-top:1rem;padding:0.5rem 1rem;background:#2563eb;color:white;border:none;border-radius:0.25rem;cursor:pointer">Recharger</button>
</div>
<script>
window.onerror = function(msg, url, line, col, err) {
var el = document.getElementById('error-root');
var msgEl = document.getElementById('error-message');
el.style.display = 'block';
msgEl.textContent = (err && err.stack) ? err.stack : msg;
document.getElementById('root').style.display = 'none';
};
window.addEventListener('unhandledrejection', function(e) {
var el = document.getElementById('error-root');
var msgEl = document.getElementById('error-message');
el.style.display = 'block';
msgEl.textContent = (e.reason && e.reason.stack) ? e.reason.stack : String(e.reason);
document.getElementById('root').style.display = 'none';
});
</script>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
+8
View File
@@ -0,0 +1,8 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
}
}
}
+14
View File
@@ -0,0 +1,14 @@
server {
listen 80;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
+9249
View File
File diff suppressed because it is too large Load Diff
+48
View File
@@ -0,0 +1,48 @@
{
"name": "gestiondescompetences",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint .",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@fontsource-variable/geist": "^5.2.9",
"@supabase/supabase-js": "^2.105.4",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"d3-force": "^3.0.0",
"lucide-react": "^1.16.0",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"react-router-dom": "^7.15.1",
"recharts": "^3.8.1",
"shadcn": "^4.7.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0",
"ws": "^8.20.1"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@tailwindcss/vite": "^4.3.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^10.3.0",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0",
"pg": "^8.21.0",
"tailwindcss": "^4.3.0",
"vite": "^8.0.12",
"vitest": "^4.1.7"
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+68
View File
@@ -0,0 +1,68 @@
import { readFileSync } from 'fs'
import { resolve, dirname } from 'path'
import { fileURLToPath } from 'url'
const __dirname = dirname(fileURLToPath(import.meta.url))
const envFile = process.argv.includes('--cloud') ? '.env.cloud' : '.env'
const envPath = resolve(__dirname, '..', envFile)
const envContent = readFileSync(envPath, 'utf8')
function readEnv(key) {
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 SERVICE_ROLE_KEY = readEnv('VITE_SUPABASE_SERVICE_ROLE_KEY')
if (!SUPABASE_URL || !SERVICE_ROLE_KEY) {
console.error(`Missing VITE_SUPABASE_URL or VITE_SUPABASE_SERVICE_ROLE_KEY in ${envFile}`)
process.exit(1)
}
async function runSql(sql) {
const url = `${SUPABASE_URL}/pg/v1/sql`
const res = await fetch(url, {
method: 'POST',
headers: {
'apikey': SERVICE_ROLE_KEY,
'Authorization': `Bearer ${SERVICE_ROLE_KEY}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ query: sql }),
})
const text = await res.text()
if (!res.ok) {
throw new Error(`SQL query failed (${res.status}): ${text.slice(0, 500)}`)
}
return text
}
async function main() {
console.log(`📁 Utilisation de ${envFile}`)
console.log(`🔗 Cible : ${SUPABASE_URL}\n`)
const migrations = [
{ file: 'supabase/migrations/001_init.sql', label: 'Migration 001 — Structure initiale' },
{ file: 'supabase/migrations/002_rls_and_indexes.sql', label: 'Migration 002 — RLS + Indexes' },
]
for (const m of migrations) {
const filePath = resolve(__dirname, '..', m.file)
console.log(`${m.label}...`)
const sql = readFileSync(filePath, 'utf8')
try {
await runSql(sql)
console.log(`${m.file} — OK\n`)
} catch (err) {
console.error(`${m.file}${err.message}\n`)
}
}
console.log('=== Migrations terminées ===')
}
main().catch(err => {
console.error('ERREUR:', err.message)
process.exit(1)
})
+235
View File
@@ -0,0 +1,235 @@
import { readFileSync } from 'fs'
import { resolve, dirname } from 'path'
import { fileURLToPath } from 'url'
const __dirname = dirname(fileURLToPath(import.meta.url))
const envPath = resolve(__dirname, '..', '.env')
const envContent = readFileSync(envPath, 'utf8')
function readEnv(key) {
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 = {
'apikey': ANON_KEY,
'Authorization': `Bearer ${SERVICE_ROLE_KEY}`,
'Content-Type': 'application/json',
'Prefer': 'return=representation',
}
async function api(method, path, body) {
const url = `${SUPABASE_URL}${path}`
const res = await fetch(url, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
})
const text = await res.text()
if (!res.ok) {
throw new Error(`${method} ${path}${res.status}: ${text.slice(0, 200)}`)
}
try { return JSON.parse(text) } catch { return text }
}
const COLLABORATORS = [
{ name: 'Alice Martin', email: 'alice.martin@example.com' },
{ name: 'Bob Bernard', email: 'bob.bernard@example.com' },
{ name: 'Chloé Dubois', email: 'chloe.dubois@example.com' },
{ name: 'David Petit', email: 'david.petit@example.com' },
{ name: 'Emma Leroy', email: 'emma.leroy@example.com' },
{ name: 'François Moreau', email: 'francois.moreau@example.com' },
{ name: 'Gaëlle Lambert', email: 'gaelle.lambert@example.com' },
{ name: 'Hugo Girard', email: 'hugo.girard@example.com' },
{ name: 'Inès Roux', email: 'ines.roux@example.com' },
{ name: 'Jules Vincent', email: 'jules.vincent@example.com' },
{ name: 'Karine Fournier', email: 'karine.fournier@example.com' },
{ name: 'Lucas Morel', email: 'lucas.morel@example.com' },
{ name: 'Manon Lefebvre', email: 'manon.lefebvre@example.com' },
{ name: 'Nathan Mercier', email: 'nathan.mercier@example.com' },
{ name: 'Océane Caron', email: 'oceane.caron@example.com' },
{ name: 'Pierre Gauthier', email: 'pierre.gauthier@example.com' },
{ name: 'Quitterie Perrin', email: 'quitterie.perrin@example.com' },
{ name: 'Romain Boucher', email: 'romain.boucher@example.com' },
{ name: 'Sarah Dumont', email: 'sarah.dumont@example.com' },
{ name: 'Thomas Giraud', email: 'thomas.giraud@example.com' },
]
const SKILLS_BY_CATEGORY = {
'Réseau': ['Routage & Switching', 'Firewall', 'VPN', 'Wireshark / Analyse'],
'Système': ['Linux', 'Windows Server', 'Virtualisation (Proxmox)', 'Ansible'],
'Cloud': ['AWS', 'Azure', 'GCP', 'Terraform'],
'Sécurité': ['Pentest', 'SOC / SIEM', 'PKI', 'ISO 27001'],
'Base de données': ['PostgreSQL', 'MySQL', 'MongoDB', 'Admin BDD'],
'Monitoring': ['Prometheus', 'Grafana', 'ELK Stack', 'Zabbix'],
'Stockage': ['SAN / NAS', 'Backup (Veeam)', 'Ceph', 'Minio'],
}
function randomLevel() {
return Math.floor(Math.random() * 4) + 1
}
function pickRandom(arr) {
return arr[Math.floor(Math.random() * arr.length)]
}
async function delay(ms) {
return new Promise(r => setTimeout(r, ms))
}
async function main() {
console.log('=== Début du seed ===\n')
// 1. Récupérer les catégories
console.log('1. Récupération des catégories...')
const categories = await api('GET', '/rest/v1/categories')
console.log(`${categories.length} catégories trouvées`)
// 2. Créer les skills
console.log('\n2. Création des skills...')
const skillIds = []
for (const cat of categories) {
const skills = SKILLS_BY_CATEGORY[cat.name]
if (!skills) { console.log(` ⚠ Pas de skills pour "${cat.name}"`); continue }
for (const skillName of skills) {
const existing = await api('GET', `/rest/v1/skills?name=eq.${encodeURIComponent(skillName)}&category_id=eq.${cat.id}`)
if (existing.length > 0) {
skillIds.push(existing[0].id)
console.log(`${skillName} (${cat.name}) — existe déjà`)
continue
}
const data = await api('POST', '/rest/v1/skills', { name: skillName, category_id: cat.id })
skillIds.push(data[0].id)
console.log(`${skillName} (${cat.name})`)
}
}
console.log(`${skillIds.length} skills prêts`)
// 3. Récupérer l'admin
console.log('\n3. Recherche de l\'admin...')
const members = await api('GET', '/rest/v1/members')
const adminId = members.find(m => m.role === 'admin')?.id
if (!adminId) { console.error('Aucun admin trouvé'); return }
console.log(` ✓ Admin: ${members.find(m => m.id === adminId)?.full_name} (${adminId})`)
// 4. Créer les utilisateurs Auth
console.log('\n4. Création des collaborateurs (Auth)...')
const memberIds = []
const defaultPassword = 'password123'
for (const collab of COLLABORATORS) {
const existing = await api('GET', `/rest/v1/members?email=eq.${encodeURIComponent(collab.email)}`)
if (existing.length > 0) {
console.log(`${collab.name} — existe déjà (member ID: ${existing[0].id})`)
memberIds.push(existing[0].id)
continue
}
const user = await api('POST', '/auth/v1/admin/users', {
email: collab.email,
password: defaultPassword,
email_confirm: true,
user_metadata: { full_name: collab.name },
})
console.log(`${collab.name} (${collab.email})`)
memberIds.push(user.id)
}
console.log(`${memberIds.length} collaborateurs`)
// Attendre que le trigger crée les members
console.log('\n Attente de la création des membres par le trigger...')
await delay(3000)
// Vérifier que tous les membres ont été créés
for (const memberId of memberIds) {
const m = await api('GET', `/rest/v1/members?id=eq.${memberId}`)
if (m.length === 0) {
console.log(` ⚠ Membre manquant pour ${memberId}, création manuelle...`)
const collab = COLLABORATORS.find(c => {
// On ne peut pas matcher facilement, on va juste récupérer l'email depuis auth
return true
})
const userInfo = await api('GET', `/auth/v1/admin/users/${memberId}`)
await api('POST', '/rest/v1/members', {
id: memberId,
email: userInfo.email,
full_name: userInfo.user_metadata?.full_name || '',
role: 'member',
})
}
}
// 5. Assigner des niveaux
console.log('\n5. Assignation des niveaux...')
let levelCount = 0
for (const memberId of memberIds) {
for (const skillId of skillIds) {
const level = randomLevel()
const existing = await api('GET', `/rest/v1/skill_levels?member_id=eq.${memberId}&skill_id=eq.${skillId}`)
if (existing.length > 0) {
await api('PATCH', `/rest/v1/skill_levels?member_id=eq.${memberId}&skill_id=eq.${skillId}`, { level })
} else {
await api('POST', '/rest/v1/skill_levels', { member_id: memberId, skill_id: skillId, level })
}
levelCount++
}
}
console.log(`${levelCount} niveaux assignés`)
// 6. Créer un historique pour les 10 premiers
console.log('\n6. Création de l\'historique...')
let historyCount = 0
for (let i = 0; i < Math.min(10, memberIds.length); i++) {
const memberId = memberIds[i]
const numChanges = Math.floor(Math.random() * 5) + 2
for (let j = 0; j < numChanges; j++) {
const skillId = pickRandom(skillIds)
const oldLevel = randomLevel()
const newLevel = Math.min(oldLevel + Math.floor(Math.random() * 2) + 1, 4)
if (newLevel === oldLevel) continue
const daysAgo = Math.floor(Math.random() * 30) + 1
const createdAt = new Date(Date.now() - daysAgo * 86400000).toISOString()
await api('POST', '/rest/v1/skill_history', {
member_id: memberId,
skill_id: skillId,
old_level: oldLevel,
new_level: newLevel,
changed_by: adminId,
created_at: createdAt,
})
historyCount++
}
}
console.log(`${historyCount} entrées d\'historique`)
// Résumé
const finalCategories = await api('GET', '/rest/v1/categories')
const finalSkills = await api('GET', '/rest/v1/skills')
const finalMembers = await api('GET', '/rest/v1/members')
const finalLevels = await api('GET', '/rest/v1/skill_levels')
const finalHistory = await api('GET', '/rest/v1/skill_history')
console.log('\n=== Seed terminé ===')
console.log(` ${finalCategories.length} catégories`)
console.log(` ${finalSkills.length} skills`)
console.log(` ${finalMembers.length} membres (dont ${finalMembers.filter(m => m.role === 'admin').length} admin)`)
console.log(` ${finalLevels.length} niveaux assignés`)
console.log(` ${finalHistory.length} entrées d'historique`)
}
main().catch(err => {
console.error('ERREUR:', err.message)
process.exit(1)
})
+218
View File
@@ -0,0 +1,218 @@
-- ============================================
-- Seed : 20 collaborateurs fictifs + compétences
-- À exécuter dans Supabase Studio → SQL Editor
-- ============================================
-- Fonction utilitaire pour générer un hash bcrypt (mot de passe: password123)
-- Le hash est pré-généré pour éviter d'avoir besoin de pgcrypto
-- 1. Créer les skills manquants
DO $$
DECLARE
cat_rec RECORD;
skill_id_var uuid;
BEGIN
-- Réseau
FOR cat_rec IN SELECT id FROM categories WHERE name = 'Réseau' LOOP
INSERT INTO skills (name, category_id) VALUES ('Routage & Switching', cat_rec.id) ON CONFLICT (name, category_id) DO NOTHING;
INSERT INTO skills (name, category_id) VALUES ('Firewall', cat_rec.id) ON CONFLICT (name, category_id) DO NOTHING;
INSERT INTO skills (name, category_id) VALUES ('VPN', cat_rec.id) ON CONFLICT (name, category_id) DO NOTHING;
INSERT INTO skills (name, category_id) VALUES ('Wireshark / Analyse', cat_rec.id) ON CONFLICT (name, category_id) DO NOTHING;
END LOOP;
-- Système
FOR cat_rec IN SELECT id FROM categories WHERE name = 'Système' LOOP
INSERT INTO skills (name, category_id) VALUES ('Linux', cat_rec.id) ON CONFLICT (name, category_id) DO NOTHING;
INSERT INTO skills (name, category_id) VALUES ('Windows Server', cat_rec.id) ON CONFLICT (name, category_id) DO NOTHING;
INSERT INTO skills (name, category_id) VALUES ('Virtualisation (Proxmox)', cat_rec.id) ON CONFLICT (name, category_id) DO NOTHING;
INSERT INTO skills (name, category_id) VALUES ('Ansible', cat_rec.id) ON CONFLICT (name, category_id) DO NOTHING;
END LOOP;
-- Cloud
FOR cat_rec IN SELECT id FROM categories WHERE name = 'Cloud' LOOP
INSERT INTO skills (name, category_id) VALUES ('AWS', cat_rec.id) ON CONFLICT (name, category_id) DO NOTHING;
INSERT INTO skills (name, category_id) VALUES ('Azure', cat_rec.id) ON CONFLICT (name, category_id) DO NOTHING;
INSERT INTO skills (name, category_id) VALUES ('GCP', cat_rec.id) ON CONFLICT (name, category_id) DO NOTHING;
INSERT INTO skills (name, category_id) VALUES ('Terraform', cat_rec.id) ON CONFLICT (name, category_id) DO NOTHING;
END LOOP;
-- Sécurité
FOR cat_rec IN SELECT id FROM categories WHERE name = 'Sécurité' LOOP
INSERT INTO skills (name, category_id) VALUES ('Pentest', cat_rec.id) ON CONFLICT (name, category_id) DO NOTHING;
INSERT INTO skills (name, category_id) VALUES ('SOC / SIEM', cat_rec.id) ON CONFLICT (name, category_id) DO NOTHING;
INSERT INTO skills (name, category_id) VALUES ('PKI', cat_rec.id) ON CONFLICT (name, category_id) DO NOTHING;
INSERT INTO skills (name, category_id) VALUES ('ISO 27001', cat_rec.id) ON CONFLICT (name, category_id) DO NOTHING;
END LOOP;
-- Base de données
FOR cat_rec IN SELECT id FROM categories WHERE name = 'Base de données' LOOP
INSERT INTO skills (name, category_id) VALUES ('PostgreSQL', cat_rec.id) ON CONFLICT (name, category_id) DO NOTHING;
INSERT INTO skills (name, category_id) VALUES ('MySQL', cat_rec.id) ON CONFLICT (name, category_id) DO NOTHING;
INSERT INTO skills (name, category_id) VALUES ('MongoDB', cat_rec.id) ON CONFLICT (name, category_id) DO NOTHING;
INSERT INTO skills (name, category_id) VALUES ('Admin BDD', cat_rec.id) ON CONFLICT (name, category_id) DO NOTHING;
END LOOP;
-- Monitoring
FOR cat_rec IN SELECT id FROM categories WHERE name = 'Monitoring' LOOP
INSERT INTO skills (name, category_id) VALUES ('Prometheus', cat_rec.id) ON CONFLICT (name, category_id) DO NOTHING;
INSERT INTO skills (name, category_id) VALUES ('Grafana', cat_rec.id) ON CONFLICT (name, category_id) DO NOTHING;
INSERT INTO skills (name, category_id) VALUES ('ELK Stack', cat_rec.id) ON CONFLICT (name, category_id) DO NOTHING;
INSERT INTO skills (name, category_id) VALUES ('Zabbix', cat_rec.id) ON CONFLICT (name, category_id) DO NOTHING;
END LOOP;
-- Stockage
FOR cat_rec IN SELECT id FROM categories WHERE name = 'Stockage' LOOP
INSERT INTO skills (name, category_id) VALUES ('SAN / NAS', cat_rec.id) ON CONFLICT (name, category_id) DO NOTHING;
INSERT INTO skills (name, category_id) VALUES ('Backup (Veeam)', cat_rec.id) ON CONFLICT (name, category_id) DO NOTHING;
INSERT INTO skills (name, category_id) VALUES ('Ceph', cat_rec.id) ON CONFLICT (name, category_id) DO NOTHING;
INSERT INTO skills (name, category_id) VALUES ('Minio', cat_rec.id) ON CONFLICT (name, category_id) DO NOTHING;
END LOOP;
END $$;
-- 2. Créer les utilisateurs dans auth.users et members
-- Extension pgcrypto pour le hash des mots de passe
CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- Insérer les utilisateurs
DO $$
DECLARE
users_data text[][] := ARRAY[
['alice.martin@example.com', 'Alice Martin'],
['bob.bernard@example.com', 'Bob Bernard'],
['chloe.dubois@example.com', 'Chloé Dubois'],
['david.petit@example.com', 'David Petit'],
['emma.leroy@example.com', 'Emma Leroy'],
['francois.moreau@example.com', 'François Moreau'],
['gaelle.lambert@example.com', 'Gaëlle Lambert'],
['hugo.girard@example.com', 'Hugo Girard'],
['ines.roux@example.com', 'Inès Roux'],
['jules.vincent@example.com', 'Jules Vincent'],
['karine.fournier@example.com', 'Karine Fournier'],
['lucas.morel@example.com', 'Lucas Morel'],
['manon.lefebvre@example.com', 'Manon Lefebvre'],
['nathan.mercier@example.com', 'Nathan Mercier'],
['oceane.caron@example.com', 'Océane Caron'],
['pierre.gauthier@example.com', 'Pierre Gauthier'],
['quitterie.perrin@example.com', 'Quitterie Perrin'],
['romain.boucher@example.com', 'Romain Boucher'],
['sarah.dumont@example.com', 'Sarah Dumont'],
['thomas.giraud@example.com', 'Thomas Giraud']
];
i int;
user_email text;
user_name text;
new_user_id uuid;
existing_id uuid;
BEGIN
FOR i IN 1..array_length(users_data, 1) LOOP
user_email := users_data[i][1];
user_name := users_data[i][2];
-- Vérifier si le membre existe déjà
SELECT id INTO existing_id FROM members WHERE email = user_email;
IF existing_id IS NOT NULL THEN
RAISE NOTICE '⏩ % existe déjà (id: %)', user_name, existing_id;
CONTINUE;
END IF;
-- Vérifier si l'utilisateur auth existe déjà
SELECT id INTO existing_id FROM auth.users WHERE email = user_email;
IF existing_id IS NOT NULL THEN
RAISE NOTICE '⏩ % existe dans auth.users, création du member manquant...', user_name;
INSERT INTO members (id, email, full_name, role)
VALUES (existing_id, user_email, user_name, 'member')
ON CONFLICT (id) DO NOTHING;
CONTINUE;
END IF;
-- Créer l'utilisateur auth
new_user_id := gen_random_uuid();
INSERT INTO auth.users (
id, instance_id, aud, role, email,
encrypted_password, email_confirmed_at,
raw_user_meta_data, created_at, updated_at,
confirmation_token, email_change, email_change_token_new, recovery_token, is_super_admin
) VALUES (
new_user_id,
'00000000-0000-0000-0000-000000000000',
'authenticated',
'authenticated',
user_email,
crypt('password123', gen_salt('bf')),
now(),
jsonb_build_object('full_name', user_name),
now(),
now(),
'', '', '', '',
false
);
-- Créer la ligne dans members manuellement
INSERT INTO members (id, email, full_name, role)
VALUES (new_user_id, user_email, user_name, 'member');
RAISE NOTICE '✓ % créé (id: %)', user_name, new_user_id;
END LOOP;
END $$;
-- 3. Assigner des niveaux de compétence aléatoires
DO $$
DECLARE
member_rec RECORD;
skill_rec RECORD;
level_val int;
BEGIN
FOR member_rec IN SELECT id FROM members WHERE role = 'member' LOOP
FOR skill_rec IN SELECT id FROM skills LOOP
level_val := floor(random() * 4) + 1;
INSERT INTO skill_levels (member_id, skill_id, level)
VALUES (member_rec.id, skill_rec.id, level_val)
ON CONFLICT (member_id, skill_id) DO UPDATE SET level = level_val;
END LOOP;
END LOOP;
RAISE NOTICE 'Niveaux assignés à tous les membres';
END $$;
-- 4. Créer un historique pour les 10 premiers membres
DO $$
DECLARE
member_rec RECORD;
skill_rec RECORD;
admin_id uuid;
skill_ids uuid[];
old_lvl int;
new_lvl int;
num_changes int;
days_ago int;
BEGIN
-- Récupérer l'ID de l'admin
SELECT id INTO admin_id FROM members WHERE role = 'admin' LIMIT 1;
IF admin_id IS NULL THEN
RAISE NOTICE '⚠ Aucun admin trouvé, historique ignoré';
RETURN;
END IF;
-- Récupérer tous les skill IDs
SELECT array_agg(id) INTO skill_ids FROM skills;
FOR member_rec IN SELECT id FROM members WHERE role = 'member' ORDER BY created_at LIMIT 10 LOOP
num_changes := floor(random() * 5) + 2;
FOR i IN 1..num_changes LOOP
skill_rec := (SELECT s FROM unnest(skill_ids) AS s ORDER BY random() LIMIT 1);
old_lvl := floor(random() * 3) + 1;
new_lvl := old_lvl + floor(random() * (4 - old_lvl)) + 1;
days_ago := floor(random() * 30) + 1;
INSERT INTO skill_history (member_id, skill_id, old_level, new_level, changed_by, created_at)
VALUES (member_rec.id, skill_rec, old_lvl, new_lvl, admin_id, now() - (days_ago || ' days')::interval);
END LOOP;
END LOOP;
RAISE NOTICE 'Historique créé';
END $$;
-- 5. Afficher le résumé
SELECT 'RÉSULTAT DU SEED' AS "";
SELECT 'Catégories' AS "Table", count(*) AS "Total" FROM categories
UNION ALL
SELECT 'Skills', count(*) FROM skills
UNION ALL
SELECT 'Membres', count(*) FROM members
UNION ALL
SELECT 'Niveaux', count(*) FROM skill_levels
UNION ALL
SELECT 'Historique', count(*) FROM skill_history;
+53
View File
@@ -0,0 +1,53 @@
{
"version": 1,
"skills": {
"frontend-design": {
"source": "anthropics/skills",
"sourceType": "github",
"skillPath": "skills/frontend-design/SKILL.md",
"computedHash": "063a0e6448123cd359ad0044cc46b0e490cc7964d45ef4bb9fd842bd2ffbca67"
},
"grill-me": {
"source": "mattpocock/skills",
"sourceType": "github",
"skillPath": "skills/productivity/grill-me/SKILL.md",
"computedHash": "784f0dbb7403b0f00324bce9a112f715342777a0daee7bbb7385f9c6f0a170ea"
},
"improve-codebase-architecture": {
"source": "mattpocock/skills",
"sourceType": "github",
"skillPath": "skills/engineering/improve-codebase-architecture/SKILL.md",
"computedHash": "ef32aea0a8fab9b365ff9e08a95f8d353e20ca21ea46ec2e73587c86dd341351"
},
"pptx": {
"source": "anthropics/skills",
"sourceType": "github",
"skillPath": "skills/pptx/SKILL.md",
"computedHash": "6b8b859d26f93aa059c9870d1ab76b44ab69e3d6757ce4a03cc94cb760888073"
},
"shadcn": {
"source": "shadcn/ui",
"sourceType": "github",
"skillPath": "skills/shadcn/SKILL.md",
"computedHash": "80a6226e78f6d1fe464214ae0ef449d49d8ffaa3e7704f011e9b418c678ad4d1"
},
"supabase": {
"source": "supabase/agent-skills",
"sourceType": "github",
"skillPath": "skills/supabase/SKILL.md",
"computedHash": "1bb189e255c0e91161f14c618dce0eccf68174ceb963664cef7761b2f90cb466"
},
"supabase-postgres-best-practices": {
"source": "supabase/agent-skills",
"sourceType": "github",
"skillPath": "skills/supabase-postgres-best-practices/SKILL.md",
"computedHash": "292c93e5a86e2429204bc37abe26b3c9023c4760eb02418462887f2082f118ce"
},
"ui-ux-pro-max": {
"source": "nextlevelbuilder/ui-ux-pro-max-skill",
"sourceType": "github",
"skillPath": ".claude/skills/ui-ux-pro-max/SKILL.md",
"computedHash": "0a413bf988d06481f69bb81df2070741c3ba12dd9f1be2706d57f259c905992d"
}
}
}
+70
View File
@@ -0,0 +1,70 @@
import { lazy, Suspense } from 'react'
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import { AuthProvider } from '@/context/AuthContext'
import { ProtectedRoute, AdminRoute } from '@/components/ProtectedRoute'
import { ErrorBoundary } from '@/components/ErrorBoundary'
import { Layout } from '@/components/Layout'
import { Toaster } from 'sonner'
import { Login } from '@/pages/Login'
import { Register } from '@/pages/Register'
import { AcceptInvite } from '@/pages/AcceptInvite'
import { Dashboard } from '@/pages/Dashboard'
import { SkillMatrix } from '@/pages/SkillMatrix'
import { History } from '@/pages/History'
import { Profile } from '@/pages/Profile'
const Members = lazy(() => import('@/pages/Members').then(m => ({ default: m.Members })))
const Skills = lazy(() => import('@/pages/Skills').then(m => ({ default: m.Skills })))
function AppLayout({ children }) {
return <Layout>{children}</Layout>
}
function SuspenseWrapper({ children }) {
return (
<Suspense fallback={<div className="flex items-center justify-center min-h-[60vh] text-gray-400">Chargement...</div>}>
{children}
</Suspense>
)
}
export default function App() {
return (
<BrowserRouter>
<AuthProvider>
<ErrorBoundary>
<Toaster />
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/accept-invite" element={<AcceptInvite />} />
<Route path="/" element={
<ProtectedRoute><AppLayout><Dashboard /></AppLayout></ProtectedRoute>
} />
<Route path="/matrix" element={
<ProtectedRoute><AppLayout><SkillMatrix /></AppLayout></ProtectedRoute>
} />
<Route path="/history" element={
<ProtectedRoute><AppLayout><History /></AppLayout></ProtectedRoute>
} />
<Route path="/profile" element={
<ProtectedRoute><AppLayout><Profile /></AppLayout></ProtectedRoute>
} />
<Route path="/members" element={
<ProtectedRoute><AdminRoute><AppLayout>
<SuspenseWrapper><Members /></SuspenseWrapper>
</AppLayout></AdminRoute></ProtectedRoute>
} />
<Route path="/skills" element={
<ProtectedRoute><AdminRoute><AppLayout>
<SuspenseWrapper><Skills /></SuspenseWrapper>
</AppLayout></AdminRoute></ProtectedRoute>
} />
</Routes>
</ErrorBoundary>
</AuthProvider>
</BrowserRouter>
)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

+34
View File
@@ -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
}
}
+64
View File
@@ -0,0 +1,64 @@
import { useState } from 'react'
import { supabase } from '@/lib/supabase'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { toast } from 'sonner'
export function InviteUserModal() {
const [email, setEmail] = useState('')
const [open, setOpen] = useState(false)
const [link, setLink] = useState('')
async function handleInvite() {
const token = crypto.randomUUID()
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString()
const { error } = await supabase.from('invitations').insert({
email,
token,
expires_at: expiresAt,
})
if (error) {
toast.error("Erreur lors de l'invitation")
return
}
const inviteLink = `${window.location.origin}/accept-invite?token=${token}`
setLink(inviteLink)
toast.success('Invitation créée')
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>Inviter un membre</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Inviter un membre</DialogTitle>
</DialogHeader>
{!link ? (
<div className="space-y-4">
<Input
type="email"
placeholder="Email du membre"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<Button onClick={handleInvite}>Envoyer l'invitation</Button>
</div>
) : (
<div className="space-y-2">
<p className="text-sm text-gray-600">Lien d'invitation (à partager) :</p>
<Input readOnly value={link} onClick={(e) => e.target.select()} />
<Button onClick={() => { navigator.clipboard.writeText(link); toast.success('Copié !') }}>
Copier le lien
</Button>
</div>
)}
</DialogContent>
</Dialog>
)
}
+123
View File
@@ -0,0 +1,123 @@
import { useState } from 'react'
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { useAuth } from '@/context/AuthContext'
import { Button } from '@/components/ui/button'
import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import {
LayoutDashboard, BookOpen, Users, Table2, History, Menu, X, Sun, Moon, LogOut, User,
} from 'lucide-react'
import { useTheme } from 'next-themes'
const navItems = [
{ to: '/', label: 'Tableau de bord', icon: LayoutDashboard },
{ to: '/skills', label: 'Compétences', icon: BookOpen, admin: true },
{ to: '/members', label: 'Membres', icon: Users, admin: true },
{ to: '/matrix', label: 'Matrice', icon: Table2 },
{ to: '/history', label: 'Historique', icon: History },
]
export function Layout({ children }) {
const { profile, signOut } = useAuth()
const { theme, setTheme } = useTheme()
const location = useLocation()
const navigate = useNavigate()
const [sidebarOpen, setSidebarOpen] = useState(false)
async function handleSignOut() {
await signOut()
navigate('/login')
}
const sidebar = (
<aside className={`w-64 bg-gray-900 text-white flex flex-col shrink-0 ${sidebarOpen ? 'fixed inset-0 z-50' : 'hidden lg:flex'}`}>
<div className="p-4 border-b border-gray-700 flex items-center justify-between">
<div>
<h1 className="text-lg font-bold">Compétences</h1>
<p className="text-xs text-gray-400">Équipe SysAdmin</p>
</div>
{sidebarOpen && (
<button className="lg:hidden text-gray-400 hover:text-white" onClick={() => setSidebarOpen(false)}>
<X className="h-5 w-5" />
</button>
)}
</div>
<nav className="flex-1 p-2 space-y-1">
{navItems
.filter((item) => !item.admin || profile?.role === 'admin')
.map((item) => {
const Icon = item.icon
return (
<Link
key={item.to}
to={item.to}
onClick={() => setSidebarOpen(false)}
className={`flex items-center gap-3 px-3 py-2 rounded-md text-sm transition-colors ${
location.pathname === item.to
? 'bg-gray-700 text-white'
: 'text-gray-300 hover:bg-gray-800 hover:text-white'
}`}
>
<Icon className="h-4 w-4" />
{item.label}
</Link>
)
})}
</nav>
<div className="p-4 border-t border-gray-700 space-y-2">
<button
className="flex items-center gap-2 text-sm text-gray-400 hover:text-white w-full"
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
>
{theme === 'dark' ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
{theme === 'dark' ? 'Mode clair' : 'Mode sombre'}
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="w-full flex items-center gap-2 text-gray-300 hover:text-white">
<Avatar className="h-6 w-6">
<AvatarFallback className="text-xs">
{profile?.full_name?.charAt(0)?.toUpperCase() || '?'}
</AvatarFallback>
</Avatar>
<span className="text-sm truncate">{profile?.full_name || profile?.email}</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem onClick={() => navigate('/profile')}>
<User className="h-4 w-4 mr-2" />
Mon profil
</DropdownMenuItem>
<DropdownMenuItem onClick={handleSignOut}>
<LogOut className="h-4 w-4 mr-2" />
Déconnexion
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</aside>
)
return (
<div className="min-h-screen flex bg-gray-50 dark:bg-gray-950 dark:text-gray-100">
{sidebarOpen && (
<div className="fixed inset-0 bg-black/50 z-40 lg:hidden" onClick={() => setSidebarOpen(false)} />
)}
{sidebar}
<main className="flex-1 flex flex-col min-w-0">
<div className="lg:hidden flex items-center justify-between p-4 border-b bg-white dark:bg-gray-900 dark:border-gray-800">
<button onClick={() => setSidebarOpen(true)} className="text-gray-700 dark:text-gray-300">
<Menu className="h-5 w-5" />
</button>
<span className="font-bold text-sm">Compétences</span>
<div className="w-5" />
</div>
<div className="p-4 md:p-8 overflow-auto">
{children}
</div>
</main>
</div>
)
}
+20
View File
@@ -0,0 +1,20 @@
import { Navigate } from 'react-router-dom'
import { useAuth } from '@/context/AuthContext'
export function ProtectedRoute({ children }) {
const { user, loading } = useAuth()
if (loading) return <div className="flex items-center justify-center min-h-screen">Chargement...</div>
if (!user) return <Navigate to="/login" replace />
return children
}
export function AdminRoute({ children }) {
const { profile, loading } = useAuth()
if (loading) return <div className="flex items-center justify-center min-h-screen">Chargement...</div>
if (!profile || profile.role !== 'admin') return <Navigate to="/" replace />
return children
}
+37
View File
@@ -0,0 +1,37 @@
/* eslint-disable react-refresh/only-export-components */
import { Badge } from '@/components/ui/badge'
const levelConfig = {
1: { label: 'Débutant', class: 'bg-gray-100 text-gray-700 hover:bg-gray-200' },
2: { label: 'Intermédiaire', class: 'bg-blue-100 text-blue-700 hover:bg-blue-200' },
3: { label: 'Avancé', class: 'bg-amber-100 text-amber-700 hover:bg-amber-200' },
4: { label: 'Expert', class: 'bg-green-100 text-green-700 hover:bg-green-200' },
}
export function SkillLevelBadge({ level, onClick, className }) {
const config = levelConfig[level] || levelConfig[1]
return (
<Badge
className={`cursor-pointer ${config.class} ${className || ''}`}
onClick={onClick}
>
{level} - {config.label}
</Badge>
)
}
export function SkillLevelSelect({ value, onChange }) {
return (
<select
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="border rounded px-2 py-1 text-sm"
>
{[1, 2, 3, 4].map((l) => (
<option key={l} value={l}>{l} - {levelConfig[l].label}</option>
))}
</select>
)
}
export { levelConfig }
+83
View File
@@ -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>
)
}
+251
View File
@@ -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>
)
}
+157
View File
@@ -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>
)
}
+104
View File
@@ -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>
)
}
+126
View File
@@ -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>
)
}
+147
View File
@@ -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>
)
}
+80
View File
@@ -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>
)
}
+158
View File
@@ -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>
)
}
+37
View File
@@ -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>
)
}
+38
View File
@@ -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>
)
}
+107
View File
@@ -0,0 +1,107 @@
"use client"
import * as React from "react"
import { Avatar as AvatarPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Avatar({
className,
size = "default",
...props
}) {
return (
<AvatarPrimitive.Root
data-slot="avatar"
data-size={size}
className={cn(
"group/avatar relative flex size-8 shrink-0 rounded-full select-none after:absolute after:inset-0 after:rounded-full after:border after:border-border after:mix-blend-darken data-[size=lg]:size-10 data-[size=sm]:size-6 dark:after:mix-blend-lighten",
className
)}
{...props} />
);
}
function AvatarImage({
className,
...props
}) {
return (
<AvatarPrimitive.Image
data-slot="avatar-image"
className={cn("aspect-square size-full rounded-full object-cover", className)}
{...props} />
);
}
function AvatarFallback({
className,
...props
}) {
return (
<AvatarPrimitive.Fallback
data-slot="avatar-fallback"
className={cn(
"flex size-full items-center justify-center rounded-full bg-muted text-sm text-muted-foreground group-data-[size=sm]/avatar:text-xs",
className
)}
{...props} />
);
}
function AvatarBadge({
className,
...props
}) {
return (
<span
data-slot="avatar-badge"
className={cn(
"absolute right-0 bottom-0 z-10 inline-flex items-center justify-center rounded-full bg-primary text-primary-foreground bg-blend-color ring-2 ring-background select-none",
"group-data-[size=sm]/avatar:size-2 group-data-[size=sm]/avatar:[&>svg]:hidden",
"group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2",
"group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2",
className
)}
{...props} />
);
}
function AvatarGroup({
className,
...props
}) {
return (
<div
data-slot="avatar-group"
className={cn(
"group/avatar-group flex -space-x-2 *:data-[slot=avatar]:ring-2 *:data-[slot=avatar]:ring-background",
className
)}
{...props} />
);
}
function AvatarGroupCount({
className,
...props
}) {
return (
<div
data-slot="avatar-group-count"
className={cn(
"relative flex size-8 shrink-0 items-center justify-center rounded-full bg-muted text-sm text-muted-foreground ring-2 ring-background group-has-data-[size=lg]/avatar-group:size-10 group-has-data-[size=sm]/avatar-group:size-6 [&>svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3",
className
)}
{...props} />
);
}
export {
Avatar,
AvatarImage,
AvatarFallback,
AvatarGroup,
AvatarGroupCount,
AvatarBadge,
}
+47
View File
@@ -0,0 +1,47 @@
import * as React from "react"
import { cva } from "class-variance-authority";
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"group/badge inline-flex h-5 w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-4xl border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-all focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3!",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
secondary:
"bg-secondary text-secondary-foreground [a]:hover:bg-secondary/80",
destructive:
"bg-destructive/10 text-destructive focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:focus-visible:ring-destructive/40 [a]:hover:bg-destructive/20",
outline:
"border-border text-foreground [a]:hover:bg-muted [a]:hover:text-muted-foreground",
ghost:
"hover:bg-muted hover:text-muted-foreground dark:hover:bg-muted/50",
link: "text-primary underline-offset-4 hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props} />
);
}
export { Badge, badgeVariants }
+63
View File
@@ -0,0 +1,63 @@
import * as React from "react"
import { cva } from "class-variance-authority";
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props} />
);
}
export { Button, buttonVariants }
+114
View File
@@ -0,0 +1,114 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({
className,
size = "default",
...props
}) {
return (
<div
data-slot="card"
data-size={size}
className={cn(
"group/card flex flex-col gap-4 overflow-hidden rounded-xl bg-card py-4 text-sm text-card-foreground ring-1 ring-foreground/10 has-data-[slot=card-footer]:pb-0 has-[>img:first-child]:pt-0 data-[size=sm]:gap-3 data-[size=sm]:py-3 data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
className
)}
{...props} />
);
}
function CardHeader({
className,
...props
}) {
return (
<div
data-slot="card-header"
className={cn(
"group/card-header @container/card-header grid auto-rows-min items-start gap-1 rounded-t-xl px-4 group-data-[size=sm]/card:px-3 has-data-[slot=card-action]:grid-cols-[1fr_auto] has-data-[slot=card-description]:grid-rows-[auto_auto] [.border-b]:pb-4 group-data-[size=sm]/card:[.border-b]:pb-3",
className
)}
{...props} />
);
}
function CardTitle({
className,
...props
}) {
return (
<div
data-slot="card-title"
className={cn(
"font-heading text-base leading-snug font-medium group-data-[size=sm]/card:text-sm",
className
)}
{...props} />
);
}
function CardDescription({
className,
...props
}) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props} />
);
}
function CardAction({
className,
...props
}) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props} />
);
}
function CardContent({
className,
...props
}) {
return (
<div
data-slot="card-content"
className={cn("px-4 group-data-[size=sm]/card:px-3", className)}
{...props} />
);
}
function CardFooter({
className,
...props
}) {
return (
<div
data-slot="card-footer"
className={cn(
"flex items-center rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/card:p-3",
className
)}
{...props} />
);
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}
+151
View File
@@ -0,0 +1,151 @@
import * as React from "react"
import { Dialog as DialogPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { XIcon } from "lucide-react"
function Dialog({
...props
}) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
}
function DialogTrigger({
...props
}) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({
...props
}) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
}
function DialogClose({
...props
}) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogOverlay({
className,
...props
}) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
className
)}
{...props} />
);
}
function DialogContent({
className,
children,
showCloseButton = true,
...props
}) {
return (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"fixed top-1/2 left-1/2 z-50 grid w-full max-w-[calc(100%-2rem)] -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-sm text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props}>
{children}
{showCloseButton && (
<DialogPrimitive.Close data-slot="dialog-close" asChild>
<Button variant="ghost" className="absolute top-2 right-2" size="icon-sm">
<XIcon />
<span className="sr-only">Close</span>
</Button>
</DialogPrimitive.Close>
)}
</DialogPrimitive.Content>
</DialogPortal>
);
}
function DialogHeader({
className,
...props
}) {
return (
<div
data-slot="dialog-header"
className={cn("flex flex-col gap-2", className)}
{...props} />
);
}
function DialogFooter({
className,
showCloseButton = false,
children,
...props
}) {
return (
<div
data-slot="dialog-footer"
className={cn(
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 sm:flex-row sm:justify-end",
className
)}
{...props}>
{children}
{showCloseButton && (
<DialogPrimitive.Close asChild>
<Button variant="outline">Close</Button>
</DialogPrimitive.Close>
)}
</div>
);
}
function DialogTitle({
className,
...props
}) {
return (
<DialogPrimitive.Title
data-slot="dialog-title"
className={cn("font-heading text-base leading-none font-medium", className)}
{...props} />
);
}
function DialogDescription({
className,
...props
}) {
return (
<DialogPrimitive.Description
data-slot="dialog-description"
className={cn(
"text-sm text-muted-foreground *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
className
)}
{...props} />
);
}
export {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
}
+235
View File
@@ -0,0 +1,235 @@
import * as React from "react"
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { CheckIcon, ChevronRightIcon } from "lucide-react"
function DropdownMenu({
...props
}) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
}
function DropdownMenuPortal({
...props
}) {
return (<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />);
}
function DropdownMenuTrigger({
...props
}) {
return (<DropdownMenuPrimitive.Trigger data-slot="dropdown-menu-trigger" {...props} />);
}
function DropdownMenuContent({
className,
align = "start",
sideOffset = 4,
...props
}) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
align={align}
className={cn(
"z-50 max-h-(--radix-dropdown-menu-content-available-height) w-(--radix-dropdown-menu-trigger-width) min-w-32 origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover p-1 text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:overflow-hidden data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props} />
</DropdownMenuPrimitive.Portal>
);
}
function DropdownMenuGroup({
...props
}) {
return (<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />);
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"group/dropdown-menu-item relative flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 data-[variant=destructive]:*:[svg]:text-destructive",
className
)}
{...props} />
);
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
inset,
...props
}) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-checkbox-item-indicator">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
);
}
function DropdownMenuRadioGroup({
...props
}) {
return (<DropdownMenuPrimitive.RadioGroup data-slot="dropdown-menu-radio-group" {...props} />);
}
function DropdownMenuRadioItem({
className,
children,
inset,
...props
}) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
data-inset={inset}
className={cn(
"relative flex cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground focus:**:text-accent-foreground data-inset:pl-7 data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}>
<span
className="pointer-events-none absolute right-2 flex items-center justify-center"
data-slot="dropdown-menu-radio-item-indicator">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
);
}
function DropdownMenuLabel({
className,
inset,
...props
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-1.5 py-1 text-xs font-medium text-muted-foreground data-inset:pl-7",
className
)}
{...props} />
);
}
function DropdownMenuSeparator({
className,
...props
}) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props} />
);
}
function DropdownMenuShortcut({
className,
...props
}) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground group-focus/dropdown-menu-item:text-accent-foreground",
className
)}
{...props} />
);
}
function DropdownMenuSub({
...props
}) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-1.5 rounded-md px-1.5 py-1 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-inset:pl-7 data-open:bg-accent data-open:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}>
{children}
<ChevronRightIcon className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
);
}
function DropdownMenuSubContent({
className,
...props
}) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"z-50 min-w-[96px] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-lg bg-popover p-1 text-popover-foreground shadow-lg ring-1 ring-foreground/10 duration-100 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
className
)}
{...props} />
);
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}
+22
View File
@@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({
className,
type,
...props
}) {
return (
<input
type={type}
data-slot="input"
className={cn(
"h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
className
)}
{...props} />
);
}
export { Input }
+182
View File
@@ -0,0 +1,182 @@
import * as React from "react"
import { Select as SelectPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"
function Select({
...props
}) {
return <SelectPrimitive.Root data-slot="select" {...props} />;
}
function SelectGroup({
className,
...props
}) {
return (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props} />
);
}
function SelectValue({
...props
}) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({
className,
size = "default",
children,
...props
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-transparent py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:bg-input/30 dark:hover:bg-input/50 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
data-align-trigger={position === "item-aligned"}
className={cn(
"relative z-50 max-h-(--radix-select-content-available-height) min-w-36 origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-foreground/10 duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
position ==="popper"&&"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
align={align}
{...props}>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
data-position={position}
className={cn(
"data-[position=popper]:h-(--radix-select-trigger-height) data-[position=popper]:w-full data-[position=popper]:min-w-(--radix-select-trigger-width)",
position === "popper" && ""
)}>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
function SelectLabel({
className,
...props
}) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
{...props} />
);
}
function SelectItem({
className,
children,
...props
}) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}>
<span
className="pointer-events-none absolute right-2 flex size-4 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="pointer-events-none" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
);
}
function SelectSeparator({
className,
...props
}) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
{...props} />
);
}
function SelectScrollUpButton({
className,
...props
}) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}>
<ChevronUpIcon />
</SelectPrimitive.ScrollUpButton>
);
}
function SelectScrollDownButton({
className,
...props
}) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"z-10 flex cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}>
<ChevronDownIcon />
</SelectPrimitive.ScrollDownButton>
);
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}
+50
View File
@@ -0,0 +1,50 @@
"use client"
import { useTheme } from "next-themes"
import { Toaster as Sonner } from "sonner";
import { CircleCheckIcon, InfoIcon, TriangleAlertIcon, OctagonXIcon, Loader2Icon } from "lucide-react"
const Toaster = ({
...props
}) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme}
className="toaster group"
icons={{
success: (
<CircleCheckIcon className="size-4" />
),
info: (
<InfoIcon className="size-4" />
),
warning: (
<TriangleAlertIcon className="size-4" />
),
error: (
<OctagonXIcon className="size-4" />
),
loading: (
<Loader2Icon className="size-4 animate-spin" />
),
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)"
}
}
toastOptions={{
classNames: {
toast: "cn-toast",
},
}}
{...props} />
);
}
export { Toaster }
+123
View File
@@ -0,0 +1,123 @@
"use client"
import * as React from "react"
import { cn } from "@/lib/utils"
function Table({
className,
...props
}) {
return (
<div data-slot="table-container" className="relative w-full overflow-x-auto">
<table
data-slot="table"
className={cn("w-full caption-bottom text-sm", className)}
{...props} />
</div>
);
}
function TableHeader({
className,
...props
}) {
return (
<thead
data-slot="table-header"
className={cn("[&_tr]:border-b", className)}
{...props} />
);
}
function TableBody({
className,
...props
}) {
return (
<tbody
data-slot="table-body"
className={cn("[&_tr:last-child]:border-0", className)}
{...props} />
);
}
function TableFooter({
className,
...props
}) {
return (
<tfoot
data-slot="table-footer"
className={cn("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", className)}
{...props} />
);
}
function TableRow({
className,
...props
}) {
return (
<tr
data-slot="table-row"
className={cn(
"border-b transition-colors hover:bg-muted/50 has-aria-expanded:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props} />
);
}
function TableHead({
className,
...props
}) {
return (
<th
data-slot="table-head"
className={cn(
"h-10 px-2 text-left align-middle font-medium whitespace-nowrap text-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props} />
);
}
function TableCell({
className,
...props
}) {
return (
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0",
className
)}
{...props} />
);
}
function TableCaption({
className,
...props
}) {
return (
<caption
data-slot="table-caption"
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props} />
);
}
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
+80
View File
@@ -0,0 +1,80 @@
import * as React from "react"
import { cva } from "class-variance-authority";
import { Tabs as TabsPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Tabs({
className,
orientation = "horizontal",
...props
}) {
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
className={cn("group/tabs flex gap-2 data-horizontal:flex-col", className)}
{...props} />
);
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
},
},
defaultVariants: {
variant: "default",
},
}
)
function TabsList({
className,
variant = "default",
...props
}) {
return (
<TabsPrimitive.List
data-slot="tabs-list"
data-variant={variant}
className={cn(tabsListVariants({ variant }), className)}
{...props} />
);
}
function TabsTrigger({
className,
...props
}) {
return (
<TabsPrimitive.Trigger
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
className
)}
{...props} />
);
}
function TabsContent({
className,
...props
}) {
return (
<TabsPrimitive.Content
data-slot="tabs-content"
className={cn("flex-1 text-sm outline-none", className)}
{...props} />
);
}
export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
+60
View File
@@ -0,0 +1,60 @@
/* eslint-disable react-refresh/only-export-components */
import { createContext, useContext, useEffect, useState } from 'react'
import { supabase } from '@/lib/supabase'
const AuthContext = createContext(null)
export function AuthProvider({ children }) {
const [user, setUser] = useState(null)
const [profile, setProfile] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
supabase.auth.getSession().then(({ data: { session } }) => {
if (session?.user) {
setUser(session.user)
fetchProfile(session.user.id)
}
setLoading(false)
})
const { data: { subscription } } = supabase.auth.onAuthStateChange((_event, session) => {
if (session?.user) {
setUser(session.user)
fetchProfile(session.user.id)
} else {
setUser(null)
setProfile(null)
}
})
return () => subscription.unsubscribe()
}, [])
async function fetchProfile(userId) {
const { data } = await supabase
.from('members')
.select('*')
.eq('id', userId)
.single()
setProfile(data)
}
async function signIn(email, password) {
return supabase.auth.signInWithPassword({ email, password })
}
async function signOut() {
await supabase.auth.signOut()
setUser(null)
setProfile(null)
}
return (
<AuthContext.Provider value={{ user, profile, loading, signIn, signOut, fetchProfile }}>
{children}
</AuthContext.Provider>
)
}
export const useAuth = () => useContext(AuthContext)
+19
View File
@@ -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 }
}
+68
View File
@@ -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,
}
}
+19
View File
@@ -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 }
}
+75
View File
@@ -0,0 +1,75 @@
import { useEffect, useState, useCallback, useRef } from 'react'
import { supabase } from '@/lib/supabase'
import { toast } from 'sonner'
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) {
const { error } = await supabase.from('skill_levels').update({ level: newLevel }).eq('id', existing.id)
if (error) { toast.error(error.message); return }
} else {
const { error } = await supabase.from('skill_levels').insert({ member_id: memberId, skill_id: skillId, level: newLevel })
if (error) { toast.error(error.message); return }
}
if (oldLevel !== newLevel && changedBy) {
const { error } = await supabase.from('skill_history').insert({
member_id: memberId,
skill_id: skillId,
old_level: oldLevel || null,
new_level: newLevel,
changed_by: changedBy,
})
if (error) { toast.error(error.message); return }
}
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 }
}
+19
View File
@@ -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 }
}
+130
View File
@@ -0,0 +1,130 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "@fontsource-variable/geist";
@custom-variant dark (&:is(.dark *));
@theme inline {
--font-heading: var(--font-sans);
--font-sans: 'Geist Variable', sans-serif;
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--color-foreground: var(--foreground);
--color-background: var(--background);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}
+6
View File
@@ -0,0 +1,6 @@
import { createClient } from '@supabase/supabase-js'
const supabaseUrl = import.meta.env.VITE_SUPABASE_URL
const supabaseAnonKey = import.meta.env.VITE_SUPABASE_ANON_KEY
export const supabase = createClient(supabaseUrl, supabaseAnonKey)
+6
View File
@@ -0,0 +1,6 @@
import { clsx } from "clsx";
import { twMerge } from "tailwind-merge"
export function cn(...inputs) {
return twMerge(clsx(inputs));
}
+13
View File
@@ -0,0 +1,13 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { ThemeProvider } from 'next-themes'
import './index.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<App />
</ThemeProvider>
</StrictMode>,
)
+110
View File
@@ -0,0 +1,110 @@
import { useState, useEffect } from 'react'
import { useSearchParams, useNavigate } from 'react-router-dom'
import { supabase } from '@/lib/supabase'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { toast } from 'sonner'
export function AcceptInvite() {
const [searchParams] = useSearchParams()
const token = searchParams.get('token')
const navigate = useNavigate()
const [email, setEmail] = useState('')
const [name, setName] = useState('')
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(true)
const [valid, setValid] = useState(false)
/* eslint-disable react-hooks/set-state-in-effect */
useEffect(() => {
let cancelled = false
async function checkToken() {
const { data } = await supabase
.from('invitations')
.select('*')
.eq('token', token)
.eq('accepted', false)
.gte('expires_at', new Date().toISOString())
.single()
if (!cancelled) {
if (data) {
setEmail(data.email)
setValid(true)
}
setLoading(false)
}
}
if (token) checkToken()
else setLoading(false)
return () => { cancelled = true }
}, [token])
/* eslint-enable react-hooks/set-state-in-effect */
async function handleSubmit(e) {
e.preventDefault()
setLoading(true)
const { error } = await supabase.auth.signUp({
email,
password,
options: { data: { full_name: name } },
})
if (error) {
toast.error(error.message)
setLoading(false)
return
}
await supabase.from('invitations').update({ accepted: true }).eq('token', token)
.then()
.catch(() => {})
toast.success('Compte créé ! Vous pouvez vous connecter.')
navigate('/login')
}
if (loading) return <div className="min-h-screen flex items-center justify-center">Vérification...</div>
if (!valid) return (
<div className="min-h-screen flex items-center justify-center">
<Card>
<CardContent className="p-8 text-center">
<p className="text-red-600">Lien d'invitation invalide ou expiré.</p>
</CardContent>
</Card>
</div>
)
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-950">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle className="text-2xl text-center">Accepter l'invitation</CardTitle>
<p className="text-sm text-gray-500 dark:text-gray-400 text-center mt-1">{email}</p>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<Input
placeholder="Nom complet"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
<Input
type="password"
placeholder="Mot de passe"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={6}
/>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? 'Création...' : 'Créer mon compte'}
</Button>
</form>
</CardContent>
</Card>
</div>
)
}
+112
View File
@@ -0,0 +1,112 @@
import { useEffect, useState } from 'react'
import { supabase } from '@/lib/supabase'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { SkillLevelBadge } from '@/components/SkillLevelBadge'
export function Dashboard() {
const [stats, setStats] = useState({ members: 0, skills: 0, categories: 0 })
const [recentChanges, setRecentChanges] = useState([])
const [topSkills, setTopSkills] = useState([])
useEffect(() => {
async function load() {
const [members, skills, categories, history] = await Promise.all([
supabase.from('members').select('*', { count: 'exact', head: true }),
supabase.from('skills').select('*', { count: 'exact', head: true }),
supabase.from('categories').select('*', { count: 'exact', head: true }),
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(10),
])
setStats({
members: members.count || 0,
skills: skills.count || 0,
categories: categories.count || 0,
})
setRecentChanges(history.data || [])
const { data: levels } = await supabase
.from('skill_levels')
.select('skill_id, level, skill:skill_id(name)')
if (levels) {
const avgMap = {}
levels.forEach((l) => {
if (!avgMap[l.skill_id]) avgMap[l.skill_id] = { name: l.skill.name, total: 0, count: 0 }
avgMap[l.skill_id].total += l.level
avgMap[l.skill_id].count += 1
})
const sorted = Object.values(avgMap)
.map((s) => ({ ...s, avg: (s.total / s.count).toFixed(1) }))
.sort((a, b) => b.avg - a.avg)
.slice(0, 5)
setTopSkills(sorted)
}
}
load()
}, [])
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">Tableau de bord</h1>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card>
<CardHeader><CardTitle className="text-lg">Membres</CardTitle></CardHeader>
<CardContent><p className="text-3xl font-bold">{stats.members}</p></CardContent>
</Card>
<Card>
<CardHeader><CardTitle className="text-lg">Compétences</CardTitle></CardHeader>
<CardContent><p className="text-3xl font-bold">{stats.skills}</p></CardContent>
</Card>
<Card>
<CardHeader><CardTitle className="text-lg">Catégories</CardTitle></CardHeader>
<CardContent><p className="text-3xl font-bold">{stats.categories}</p></CardContent>
</Card>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Card>
<CardHeader><CardTitle className="text-lg">Compétences les mieux notées</CardTitle></CardHeader>
<CardContent>
{topSkills.length === 0 ? (
<p className="text-gray-500 dark:text-gray-400">Aucune évaluation pour le moment</p>
) : (
<ul className="space-y-2">
{topSkills.map((s) => (
<li key={s.name} className="flex items-center justify-between">
<span>{s.name}</span>
<Badge>{s.avg}/4</Badge>
</li>
))}
</ul>
)}
</CardContent>
</Card>
<Card>
<CardHeader><CardTitle className="text-lg">Dernières évolutions</CardTitle></CardHeader>
<CardContent className="max-h-80 overflow-auto">
{recentChanges.length === 0 ? (
<p className="text-gray-500 dark:text-gray-400">Aucun changement récent</p>
) : (
<ul className="space-y-3">
{recentChanges.map((c) => (
<li key={c.id} className="text-sm border-b dark:border-gray-800 pb-2 last:border-0">
<span className="font-medium">{c.member?.full_name}</span>
{' '}a mis à jour{' '}
<span className="font-medium">{c.skill?.name}</span>
{' '}de <SkillLevelBadge level={c.old_level || 1} /> <SkillLevelBadge level={c.new_level} />
</li>
))}
</ul>
)}
</CardContent>
</Card>
</div>
</div>
)
}
+91
View File
@@ -0,0 +1,91 @@
import { useMembers } from '@/hooks/useMembers'
import { useSkills } from '@/hooks/useSkills'
import { useHistory } from '@/hooks/useHistory'
import { Card, CardContent } from '@/components/ui/card'
import { SkillLevelBadge } from '@/components/SkillLevelBadge'
import { Button } from '@/components/ui/button'
import { ChevronLeft, ChevronRight } from 'lucide-react'
export function History() {
const { members } = useMembers()
const { skills } = useSkills()
const { history, loading, count, page, totalPages, filters, setFilter, nextPage, prevPage } = useHistory()
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold">Historique des évolutions</h1>
<div className="flex gap-4">
<select
className="border rounded px-2 py-1 text-sm dark:bg-gray-800 dark:border-gray-700"
value={filters.memberId}
onChange={(e) => setFilter('memberId', e.target.value)}
>
<option value="all">Tous les membres</option>
{members.map((m) => (
<option key={m.id} value={m.id}>{m.full_name || m.email}</option>
))}
</select>
<select
className="border rounded px-2 py-1 text-sm dark:bg-gray-800 dark:border-gray-700"
value={filters.skillId}
onChange={(e) => setFilter('skillId', e.target.value)}
>
<option value="all">Toutes les compétences</option>
{skills.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</select>
</div>
<Card>
<CardContent className="p-0">
{loading ? (
<p className="p-6 text-gray-400">Chargement...</p>
) : history.length === 0 ? (
<p className="p-6 text-gray-400">Aucun historique</p>
) : (
<ul className="divide-y dark:divide-gray-800">
{history.map((h) => (
<li key={h.id} className="p-4 flex items-center justify-between">
<div className="space-y-1">
<p className="text-sm">
<span className="text-gray-500 dark:text-gray-400">Membre :</span>
{' '}<span className="font-medium">{h.member?.full_name || h.member_id?.slice(0, 8)}</span>
</p>
<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()}
</p>
</div>
<div className="flex items-center gap-2">
{h.old_level && <SkillLevelBadge level={h.old_level} />}
{h.old_level && <span className="text-gray-400"></span>}
<SkillLevelBadge level={h.new_level} />
</div>
</li>
))}
</ul>
)}
</CardContent>
</Card>
{totalPages > 1 && (
<div className="flex items-center justify-between text-sm text-gray-500 dark:text-gray-400">
<span>{count} entrées Page {page + 1} / {totalPages}</span>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={prevPage} disabled={page === 0}>
<ChevronLeft className="h-4 w-4" /> Précédent
</Button>
<Button variant="outline" size="sm" onClick={nextPage} disabled={page >= totalPages - 1}>
Suivant <ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</div>
)
}
+63
View File
@@ -0,0 +1,63 @@
import { useState } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { useAuth } from '@/context/AuthContext'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
export function Login() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const { signIn } = useAuth()
const navigate = useNavigate()
async function handleSubmit(e) {
e.preventDefault()
setError('')
const { error } = await signIn(email, password)
if (error) {
setError(error.message === 'Invalid login credentials'
? 'Email ou mot de passe incorrect'
: error.message)
} else {
navigate('/')
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle className="text-2xl text-center">Gestion des compétences</CardTitle>
<p className="text-sm text-gray-500 text-center mt-1">
Connectez-vous à votre compte
</p>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<Input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
<Input
type="password"
placeholder="Mot de passe"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
{error && <p className="text-sm text-red-600">{error}</p>}
<Button type="submit" className="w-full">Se connecter</Button>
</form>
<p className="text-sm text-center mt-4 text-gray-500">
Pas encore de compte ? <Link to="/register" className="text-blue-600 hover:underline">Créer un compte</Link>
</p>
</CardContent>
</Card>
</div>
)
}
+121
View File
@@ -0,0 +1,121 @@
import { useEffect, useState } from 'react'
import { supabase } from '@/lib/supabase'
import { useAuth } from '@/context/AuthContext'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { InviteUserModal } from '@/components/InviteUserModal'
import { Download } from 'lucide-react'
import { toast } from 'sonner'
export function Members() {
const { profile } = useAuth()
const [members, setMembers] = useState([])
const [editMember, setEditMember] = useState(null)
const [editName, setEditName] = useState('')
useEffect(() => { load() }, [])
async function load() {
const { data } = await supabase.from('members').select('*').order('created_at', { ascending: false })
if (data) setMembers(data)
}
async function updateMember() {
const { error } = await supabase.from('members').update({ full_name: editName }).eq('id', editMember.id)
if (error) { toast.error(error.message); return }
setEditMember(null)
load()
toast.success('Membre mis à jour')
}
async function deleteMember(id) {
if (!confirm('Supprimer ce membre ?')) return
const { error } = await supabase.from('members').delete().eq('id', id)
if (error) { toast.error(error.message); return }
load()
toast.success('Membre supprimé')
}
function exportCSV() {
const header = 'Nom,Email,Rôle,Inscrit le\n'
const rows = members.map((m) =>
`"${m.full_name || ''}","${m.email}","${m.role}","${new Date(m.created_at).toLocaleDateString()}"`
).join('\n')
const blob = new Blob([header + rows], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'membres.csv'
a.click()
URL.revokeObjectURL(url)
toast.success('Fichier CSV téléchargé')
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Membres</h1>
<div className="flex gap-2">
<Button variant="outline" onClick={exportCSV}>
<Download className="h-4 w-4 mr-2" />
CSV
</Button>
<InviteUserModal />
</div>
</div>
<Card>
<CardContent className="p-0">
<Table>
<TableHeader>
<TableRow>
<TableHead>Nom</TableHead>
<TableHead>Email</TableHead>
<TableHead>Rôle</TableHead>
<TableHead>Inscrit le</TableHead>
<TableHead className="w-32">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{members.map((m) => (
<TableRow key={m.id}>
<TableCell className="font-medium">{m.full_name || '—'}</TableCell>
<TableCell>{m.email}</TableCell>
<TableCell>
<Badge variant={m.role === 'admin' ? 'default' : 'secondary'}>
{m.role === 'admin' ? 'Admin' : 'Membre'}
</Badge>
</TableCell>
<TableCell>{new Date(m.created_at).toLocaleDateString()}</TableCell>
<TableCell>
<div className="flex gap-1">
<Dialog>
<DialogTrigger asChild>
<Button size="sm" variant="ghost" onClick={() => { setEditMember(m); setEditName(m.full_name) }}></Button>
</DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>Modifier le membre</DialogTitle></DialogHeader>
<div className="space-y-4">
<Input value={editName} onChange={(e) => setEditName(e.target.value)} />
<Button onClick={updateMember}>Enregistrer</Button>
</div>
</DialogContent>
</Dialog>
{m.id !== profile?.id && (
<Button size="sm" variant="ghost" onClick={() => deleteMember(m.id)}>🗑</Button>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
)
}
+46
View File
@@ -0,0 +1,46 @@
import { useState } from 'react'
import { useAuth } from '@/context/AuthContext'
import { supabase } from '@/lib/supabase'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { toast } from 'sonner'
export function Profile() {
const { profile, fetchProfile } = useAuth()
const [name, setName] = useState(profile?.full_name || '')
async function handleSave() {
const { error } = await supabase.from('members').update({ full_name: name }).eq('id', profile.id)
if (error) {
toast.error('Erreur lors de la mise à jour')
} else {
fetchProfile(profile.id)
toast.success('Profil mis à jour')
}
}
return (
<div className="max-w-lg space-y-6">
<h1 className="text-2xl font-bold">Mon profil</h1>
<Card>
<CardHeader><CardTitle>Informations</CardTitle></CardHeader>
<CardContent className="space-y-4">
<div>
<label className="text-sm text-gray-600 dark:text-gray-400">Email</label>
<p className="font-medium">{profile?.email}</p>
</div>
<div>
<label className="text-sm text-gray-600 dark:text-gray-400">Rôle</label>
<p className="font-medium capitalize">{profile?.role}</p>
</div>
<div>
<label className="text-sm text-gray-600 dark:text-gray-400">Nom complet</label>
<Input value={name} onChange={(e) => setName(e.target.value)} />
</div>
<Button onClick={handleSave}>Enregistrer</Button>
</CardContent>
</Card>
</div>
)
}
+79
View File
@@ -0,0 +1,79 @@
import { useState } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { supabase } from '@/lib/supabase'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { toast } from 'sonner'
export function Register() {
const [email, setEmail] = useState('')
const [name, setName] = useState('')
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)
const navigate = useNavigate()
async function handleSubmit(e) {
e.preventDefault()
setLoading(true)
const { error } = await supabase.auth.signUp({
email,
password,
options: { data: { full_name: name } },
})
if (error) {
toast.error(error.message)
setLoading(false)
return
}
toast.success('Compte créé ! Vérifie ta boîte email pour confirmer.')
navigate('/login')
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle className="text-2xl text-center">Créer un compte</CardTitle>
<p className="text-sm text-gray-500 text-center mt-1">
Inscris-toi pour rejoindre l'équipe
</p>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit} className="space-y-4">
<Input
placeholder="Nom complet"
value={name}
onChange={(e) => setName(e.target.value)}
required
/>
<Input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
/>
<Input
type="password"
placeholder="Mot de passe (min. 6 caractères)"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
minLength={6}
/>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? 'Création...' : 'Créer mon compte'}
</Button>
</form>
<p className="text-sm text-center mt-4 text-gray-500">
Déjà un compte ? <Link to="/login" className="text-blue-600 hover:underline">Se connecter</Link>
</p>
</CardContent>
</Card>
</div>
)
}
+252
View File
@@ -0,0 +1,252 @@
import { lazy, Suspense, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { useAuth } from '@/context/AuthContext'
import { useCategories } from '@/hooks/useCategories'
import { useSkills } from '@/hooks/useSkills'
import { useMembers } from '@/hooks/useMembers'
import { useSkillLevels } from '@/hooks/useSkillLevels'
import { SkillMatrixFilters } from '@/components/matrix/SkillMatrixFilters'
import { SkillMatrixTable } from '@/components/matrix/SkillMatrixTable'
import { 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'
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() {
const { profile } = useAuth()
const isAdmin = profile?.role === 'admin'
const currentUserId = profile?.id
const { categories } = useCategories()
const { skills } = useSkills()
const { members } = useMembers()
const { levels, updateLevel } = useSkillLevels()
const [searchParams, setSearchParams] = useSearchParams()
const viewMode = searchParams.get('view') || 'table'
const [filterCat, setFilterCat] = useState('all')
const [filterMember, setFilterMember] = useState('all')
const [filterMinLevel, setFilterMinLevel] = useState(0)
const [editing, setEditing] = useState(null)
function setViewMode(mode) {
setSearchParams(mode === 'table' ? {} : { view: mode }, { replace: true })
}
function onFilterChange(key, value) {
if (key === 'cat') setFilterCat(value)
if (key === 'member') setFilterMember(value)
if (key === 'minLevel') setFilterMinLevel(value)
}
const filteredSkills = filterCat === 'all'
? skills
: skills.filter((s) => s.category_id === filterCat)
const filteredMembers = filterMember === 'all'
? members
: members.filter((m) => m.id === filterMember)
const visibleMembers = filterMinLevel === 0
? filteredMembers
: filteredMembers.filter((m) =>
filteredSkills.some((s) => {
const key = `${m.id}-${s.id}`
return (levels[key]?.level || 0) >= filterMinLevel
})
)
async function handleUpdate(memberId, skillId, newLevel) {
await updateLevel(memberId, skillId, newLevel, profile.id)
setEditing(null)
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 (
<div className="space-y-6">
<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 items-center gap-2">
<ViewSwitcher active={viewMode} onChange={setViewMode} />
<Button variant="outline" onClick={exportCSV}>
<Download className="h-4 w-4 mr-2" />
CSV
</Button>
</div>
</div>
<SkillMatrixFilters
categories={categories}
members={members}
filterCat={filterCat}
filterMember={filterMember}
filterMinLevel={filterMinLevel}
onFilterChange={onFilterChange}
hideMember={hideMemberViews.has(viewMode)}
/>
{showLevelLegendViews.has(viewMode) && (
<div className="flex gap-4 text-sm text-gray-500 dark:text-gray-400">
<span><span className="inline-block w-3 h-3 rounded-full bg-gray-200 mr-1" /> Débutant</span>
<span><span className="inline-block w-3 h-3 rounded-full bg-blue-200 mr-1" /> Intermédiaire</span>
<span><span className="inline-block w-3 h-3 rounded-full bg-amber-200 mr-1" /> Avancé</span>
<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>
)
}
+123
View File
@@ -0,0 +1,123 @@
import { useState } from 'react'
import { supabase } from '@/lib/supabase'
import { useCategories } from '@/hooks/useCategories'
import { useSkills } from '@/hooks/useSkills'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { CategoryCard } from '@/components/skills/CategoryCard'
import { Search } from 'lucide-react'
import { toast } from 'sonner'
export function Skills() {
const { categories, refetch: refetchCats } = useCategories()
const { skills, refetch: refetchSkills } = useSkills()
const [search, setSearch] = useState('')
const [newCatName, setNewCatName] = useState('')
const [newCatColor, setNewCatColor] = useState('#3b82f6')
const [editCat, setEditCat] = useState(null)
const [newSkill, setNewSkill] = useState({ name: '', category_id: '' })
const [catDialogOpen, setCatDialogOpen] = useState(false)
const [skillDialogOpen, setSkillDialogOpen] = useState(false)
async function saveCategory() {
if (editCat) {
const { error } = await supabase.from('categories').update({ name: newCatName, color: newCatColor }).eq('id', editCat)
if (error) { toast.error(error.message); return }
} else {
const { error } = await supabase.from('categories').insert({ name: newCatName, color: newCatColor })
if (error) { toast.error(error.message); return }
}
setCatDialogOpen(false)
setEditCat(null)
setNewCatName('')
refetchCats()
toast.success('Catégorie enregistrée')
}
async function deleteCategory(id) {
const { error } = await supabase.from('categories').delete().eq('id', id)
if (error) toast.error(error.message)
else { refetchCats(); toast.success('Catégorie supprimée') }
}
async function saveSkill() {
const { error } = await supabase.from('skills').insert({ name: newSkill.name, category_id: newSkill.category_id })
if (error) { toast.error(error.message); return }
setSkillDialogOpen(false)
setNewSkill({ name: '', category_id: '' })
refetchSkills()
toast.success('Compétence ajoutée')
}
function openEditCategory(cat) {
setEditCat(cat.id)
setNewCatName(cat.name)
setNewCatColor(cat.color)
setCatDialogOpen(true)
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Compétences</h1>
<div className="flex gap-2">
<Dialog open={skillDialogOpen} onOpenChange={setSkillDialogOpen}>
<DialogTrigger asChild><Button>+ Compétence</Button></DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>Nouvelle compétence</DialogTitle></DialogHeader>
<div className="space-y-4">
<Input placeholder="Nom" value={newSkill.name} onChange={(e) => setNewSkill({ ...newSkill, name: e.target.value })} />
<select
className="w-full border rounded-md px-3 py-2 dark:bg-gray-800 dark:border-gray-700"
value={newSkill.category_id}
onChange={(e) => setNewSkill({ ...newSkill, category_id: e.target.value })}
>
<option value="">Choisir une catégorie</option>
{categories.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</select>
<Button onClick={saveSkill}>Ajouter</Button>
</div>
</DialogContent>
</Dialog>
<Dialog open={catDialogOpen} onOpenChange={(o) => { setCatDialogOpen(o); if (!o) setEditCat(null) }}>
<DialogTrigger asChild><Button variant="outline">+ Catégorie</Button></DialogTrigger>
<DialogContent>
<DialogHeader><DialogTitle>{editCat ? 'Modifier' : 'Nouvelle'} catégorie</DialogTitle></DialogHeader>
<div className="space-y-4">
<Input placeholder="Nom" value={newCatName} onChange={(e) => setNewCatName(e.target.value)} />
<Input type="color" value={newCatColor} onChange={(e) => setNewCatColor(e.target.value)} />
<Button onClick={saveCategory}>{editCat ? 'Modifier' : 'Créer'}</Button>
</div>
</DialogContent>
</Dialog>
</div>
</div>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
className="pl-10"
placeholder="Rechercher une compétence..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
{categories.map((cat) => {
const catSkills = skills.filter((s) => s.category_id === cat.id && (!search || s.name.toLowerCase().includes(search.toLowerCase())))
if (search && catSkills.length === 0) return null
return (
<CategoryCard
key={cat.id}
category={cat}
skills={catSkills}
onEdit={openEditCategory}
onDelete={deleteCategory}
/>
)
})}
</div>
)
}
+8
View File
@@ -0,0 +1,8 @@
# Supabase
.branches
.temp
# dotenvx
.env.keys
.env.local
.env.*.local
+408
View File
@@ -0,0 +1,408 @@
# For detailed configuration reference documentation, visit:
# https://supabase.com/docs/guides/local-development/cli/config
# A string used to distinguish different Supabase projects on the same host. Defaults to the
# working directory name when running `supabase init`.
project_id = "GestionDesCompetences"
[api]
enabled = true
# Port to use for the API URL.
port = 54321
# Schemas to expose in your API. Tables, views and stored procedures in this schema will get API
# endpoints. `public` and `graphql_public` schemas are included by default.
schemas = ["public", "graphql_public"]
# Extra schemas to add to the search_path of every request.
extra_search_path = ["public", "extensions"]
# The maximum number of rows returns from a view, table, or stored procedure. Limits payload size
# for accidental or malicious requests.
max_rows = 1000
[api.tls]
# Enable HTTPS endpoints locally using a self-signed certificate.
enabled = false
# Paths to self-signed certificate pair.
# cert_path = "../certs/my-cert.pem"
# key_path = "../certs/my-key.pem"
[db]
# Port to use for the local database URL.
port = 54322
# Port used by db diff command to initialize the shadow database.
shadow_port = 54320
# Maximum amount of time to wait for health check when starting the local database.
health_timeout = "2m"
# The database major version to use. This has to be the same as your remote database's. Run `SHOW
# server_version;` on the remote database to check.
major_version = 17
[db.pooler]
enabled = false
# Port to use for the local connection pooler.
port = 54329
# Specifies when a server connection can be reused by other clients.
# Configure one of the supported pooler modes: `transaction`, `session`.
pool_mode = "transaction"
# How many server connections to allow per user/database pair.
default_pool_size = 20
# Maximum number of client connections allowed.
max_client_conn = 100
# [db.vault]
# secret_key = "env(SECRET_VALUE)"
[db.migrations]
# If disabled, migrations will be skipped during a db push or reset.
enabled = true
# Specifies an ordered list of schema files that describe your database.
# Supports glob patterns relative to supabase directory: "./schemas/*.sql"
schema_paths = []
[db.seed]
# If enabled, seeds the database after migrations during a db reset.
enabled = true
# Specifies an ordered list of seed files to load during db reset.
# Supports glob patterns relative to supabase directory: "./seeds/*.sql"
sql_paths = ["./seed.sql"]
[db.network_restrictions]
# Enable management of network restrictions.
enabled = false
# List of IPv4 CIDR blocks allowed to connect to the database.
# Defaults to allow all IPv4 connections. Set empty array to block all IPs.
allowed_cidrs = ["0.0.0.0/0"]
# List of IPv6 CIDR blocks allowed to connect to the database.
# Defaults to allow all IPv6 connections. Set empty array to block all IPs.
allowed_cidrs_v6 = ["::/0"]
# Uncomment to reject non-secure connections to the database.
# [db.ssl_enforcement]
# enabled = true
[realtime]
enabled = true
# Bind realtime via either IPv4 or IPv6. (default: IPv4)
# ip_version = "IPv6"
# The maximum length in bytes of HTTP request headers. (default: 4096)
# max_header_length = 4096
[studio]
enabled = true
# Port to use for Supabase Studio.
port = 54323
# External URL of the API server that frontend connects to.
api_url = "http://127.0.0.1"
# OpenAI API Key to use for Supabase AI in the Supabase Studio.
openai_api_key = "env(OPENAI_API_KEY)"
# Email testing server. Emails sent with the local dev setup are not actually sent - rather, they
# are monitored, and you can view the emails that would have been sent from the web interface.
[inbucket]
enabled = true
# Port to use for the email testing server web interface.
port = 54324
# Uncomment to expose additional ports for testing user applications that send emails.
# smtp_port = 54325
# pop3_port = 54326
# admin_email = "admin@email.com"
# sender_name = "Admin"
[storage]
enabled = true
# The maximum file size allowed (e.g. "5MB", "500KB").
file_size_limit = "50MiB"
# Uncomment to configure local storage buckets
# [storage.buckets.images]
# public = false
# file_size_limit = "50MiB"
# allowed_mime_types = ["image/png", "image/jpeg"]
# objects_path = "./images"
# Allow connections via S3 compatible clients
[storage.s3_protocol]
enabled = true
# Image transformation API is available to Supabase Pro plan.
# [storage.image_transformation]
# enabled = true
# Store analytical data in S3 for running ETL jobs over Iceberg Catalog
# This feature is only available on the hosted platform.
[storage.analytics]
enabled = false
max_namespaces = 5
max_tables = 10
max_catalogs = 2
# Analytics Buckets is available to Supabase Pro plan.
# [storage.analytics.buckets.my-warehouse]
# Store vector embeddings in S3 for large and durable datasets
# This feature is only available on the hosted platform.
[storage.vector]
enabled = false
max_buckets = 10
max_indexes = 5
# Vector Buckets is available to Supabase Pro plan.
# [storage.vector.buckets.documents-openai]
[auth]
enabled = true
# The base URL of your website. Used as an allow-list for redirects and for constructing URLs used
# in emails.
site_url = "http://127.0.0.1:3000"
# The public URL that Auth serves on. Defaults to the API external URL with `/auth/v1` appended.
# external_url = ""
# A list of *exact* URLs that auth providers are permitted to redirect to post authentication.
additional_redirect_urls = ["https://127.0.0.1:3000"]
# How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week).
jwt_expiry = 3600
# JWT issuer URL. If not set, defaults to auth.external_url.
# jwt_issuer = ""
# Path to JWT signing key. DO NOT commit your signing keys file to git.
# signing_keys_path = "./signing_keys.json"
# If disabled, the refresh token will never expire.
enable_refresh_token_rotation = true
# Allows refresh tokens to be reused after expiry, up to the specified interval in seconds.
# Requires enable_refresh_token_rotation = true.
refresh_token_reuse_interval = 10
# Allow/disallow new user signups to your project.
enable_signup = true
# Allow/disallow anonymous sign-ins to your project.
enable_anonymous_sign_ins = false
# Allow/disallow testing manual linking of accounts
enable_manual_linking = false
# Passwords shorter than this value will be rejected as weak. Minimum 6, recommended 8 or more.
minimum_password_length = 6
# Passwords that do not meet the following requirements will be rejected as weak. Supported values
# are: `letters_digits`, `lower_upper_letters_digits`, `lower_upper_letters_digits_symbols`
password_requirements = ""
# Configure passkey sign-ins.
# [auth.passkey]
# enabled = false
# Configure WebAuthn relying party settings (required when passkey is enabled).
# [auth.webauthn]
# rp_display_name = "Supabase"
# rp_id = "localhost"
# rp_origins = ["http://127.0.0.1:3000"]
[auth.rate_limit]
# Number of emails that can be sent per hour. Requires auth.email.smtp to be enabled.
email_sent = 2
# Number of SMS messages that can be sent per hour. Requires auth.sms to be enabled.
sms_sent = 30
# Number of anonymous sign-ins that can be made per hour per IP address. Requires enable_anonymous_sign_ins = true.
anonymous_users = 30
# Number of sessions that can be refreshed in a 5 minute interval per IP address.
token_refresh = 150
# Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address (excludes anonymous users).
sign_in_sign_ups = 30
# Number of OTP / Magic link verifications that can be made in a 5 minute interval per IP address.
token_verifications = 30
# Number of Web3 logins that can be made in a 5 minute interval per IP address.
web3 = 30
# Configure one of the supported captcha providers: `hcaptcha`, `turnstile`.
# [auth.captcha]
# enabled = true
# provider = "hcaptcha"
# secret = ""
[auth.email]
# Allow/disallow new user signups via email to your project.
enable_signup = true
# If enabled, a user will be required to confirm any email change on both the old, and new email
# addresses. If disabled, only the new email is required to confirm.
double_confirm_changes = true
# If enabled, users need to confirm their email address before signing in.
enable_confirmations = false
# If enabled, users will need to reauthenticate or have logged in recently to change their password.
secure_password_change = false
# Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email.
max_frequency = "1s"
# Number of characters used in the email OTP.
otp_length = 6
# Number of seconds before the email OTP expires (defaults to 1 hour).
otp_expiry = 3600
# Use a production-ready SMTP server
# [auth.email.smtp]
# enabled = true
# host = "smtp.sendgrid.net"
# port = 587
# user = "apikey"
# pass = "env(SENDGRID_API_KEY)"
# admin_email = "admin@email.com"
# sender_name = "Admin"
# Uncomment to customize email template
# [auth.email.template.invite]
# subject = "You have been invited"
# content_path = "./supabase/templates/invite.html"
# Uncomment to customize notification email template
# [auth.email.notification.password_changed]
# enabled = true
# subject = "Your password has been changed"
# content_path = "./templates/password_changed_notification.html"
[auth.sms]
# Allow/disallow new user signups via SMS to your project.
enable_signup = false
# If enabled, users need to confirm their phone number before signing in.
enable_confirmations = false
# Template for sending OTP to users
template = "Your code is {{ .Code }}"
# Controls the minimum amount of time that must pass before sending another sms otp.
max_frequency = "5s"
# Use pre-defined map of phone number to OTP for testing.
# [auth.sms.test_otp]
# 4152127777 = "123456"
# Configure logged in session timeouts.
# [auth.sessions]
# Force log out after the specified duration.
# timebox = "24h"
# Force log out if the user has been inactive longer than the specified duration.
# inactivity_timeout = "8h"
# This hook runs before a new user is created and allows developers to reject the request based on the incoming user object.
# [auth.hook.before_user_created]
# enabled = true
# uri = "pg-functions://postgres/auth/before-user-created-hook"
# This hook runs before a token is issued and allows you to add additional claims based on the authentication method used.
# [auth.hook.custom_access_token]
# enabled = true
# uri = "pg-functions://<database>/<schema>/<hook_name>"
# Configure one of the supported SMS providers: `twilio`, `twilio_verify`, `messagebird`, `textlocal`, `vonage`.
[auth.sms.twilio]
enabled = false
account_sid = ""
message_service_sid = ""
# DO NOT commit your Twilio auth token to git. Use environment variable substitution instead:
auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)"
# Multi-factor-authentication is available to Supabase Pro plan.
[auth.mfa]
# Control how many MFA factors can be enrolled at once per user.
max_enrolled_factors = 10
# Control MFA via App Authenticator (TOTP)
[auth.mfa.totp]
enroll_enabled = false
verify_enabled = false
# Configure MFA via Phone Messaging
[auth.mfa.phone]
enroll_enabled = false
verify_enabled = false
otp_length = 6
template = "Your code is {{ .Code }}"
max_frequency = "5s"
# Configure MFA via WebAuthn
# [auth.mfa.web_authn]
# enroll_enabled = true
# verify_enabled = true
# Use an external OAuth provider. The full list of providers are: `apple`, `azure`, `bitbucket`,
# `discord`, `facebook`, `github`, `gitlab`, `google`, `keycloak`, `linkedin_oidc`, `notion`, `twitch`,
# `twitter`, `x`, `slack`, `spotify`, `workos`, `zoom`.
[auth.external.apple]
enabled = false
client_id = ""
# DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead:
secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)"
# Overrides the default auth callback URL derived from auth.external_url.
redirect_uri = ""
# Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure,
# or any other third-party OIDC providers.
url = ""
# If enabled, the nonce check will be skipped. Required for local sign in with Google auth.
skip_nonce_check = false
# If enabled, it will allow the user to successfully authenticate when the provider does not return an email address.
email_optional = false
# Allow Solana wallet holders to sign in to your project via the Sign in with Solana (SIWS, EIP-4361) standard.
# You can configure "web3" rate limit in the [auth.rate_limit] section and set up [auth.captcha] if self-hosting.
[auth.web3.solana]
enabled = false
# Use Firebase Auth as a third-party provider alongside Supabase Auth.
[auth.third_party.firebase]
enabled = false
# project_id = "my-firebase-project"
# Use Auth0 as a third-party provider alongside Supabase Auth.
[auth.third_party.auth0]
enabled = false
# tenant = "my-auth0-tenant"
# tenant_region = "us"
# Use AWS Cognito (Amplify) as a third-party provider alongside Supabase Auth.
[auth.third_party.aws_cognito]
enabled = false
# user_pool_id = "my-user-pool-id"
# user_pool_region = "us-east-1"
# Use Clerk as a third-party provider alongside Supabase Auth.
[auth.third_party.clerk]
enabled = false
# Obtain from https://clerk.com/setup/supabase
# domain = "example.clerk.accounts.dev"
# OAuth server configuration
[auth.oauth_server]
# Enable OAuth server functionality
enabled = false
# Path for OAuth consent flow UI
authorization_url_path = "/oauth/consent"
# Allow dynamic client registration
allow_dynamic_registration = false
[edge_runtime]
enabled = true
# Supported request policies: `oneshot`, `per_worker`.
# `per_worker` (default) — enables hot reload during local development.
# `oneshot` — fallback mode if hot reload causes issues (e.g. in large repos or with symlinks).
policy = "per_worker"
# Port to attach the Chrome inspector for debugging edge functions.
inspector_port = 8083
# The Deno major version to use.
deno_version = 2
# [edge_runtime.secrets]
# secret_key = "env(SECRET_VALUE)"
[analytics]
enabled = true
port = 54327
# Configure one of the supported backends: `postgres`, `bigquery`.
backend = "postgres"
# Experimental features may be deprecated any time
[experimental]
# Configures Postgres storage engine to use OrioleDB (S3)
orioledb_version = ""
# Configures S3 bucket URL, eg. <bucket_name>.s3-<region>.amazonaws.com
s3_host = "env(S3_HOST)"
# Configures S3 bucket region, eg. us-east-1
s3_region = "env(S3_REGION)"
# Configures AWS_ACCESS_KEY_ID for S3 bucket
s3_access_key = "env(S3_ACCESS_KEY)"
# Configures AWS_SECRET_ACCESS_KEY for S3 bucket
s3_secret_key = "env(S3_SECRET_KEY)"
# [experimental.pgdelta]
# When enabled, pg-delta becomes the active engine for supported schema flows.
# enabled = false
# Directory under `supabase/` where declarative files are written.
# declarative_schema_path = "./database"
# JSON string passed through to pg-delta SQL formatting.
# format_options = "{\"keywordCase\":\"upper\",\"indent\":2,\"maxWidth\":80,\"commaStyle\":\"trailing\"}"
+219
View File
@@ -0,0 +1,219 @@
-- ============================================================
-- Migration 001: Structure initiale
-- Application de gestion des compétences
-- ============================================================
-- 1. ENUMS
CREATE TYPE user_role AS ENUM ('admin', 'member');
-- 2. TABLES
-- Catégories de compétences
CREATE TABLE categories (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL UNIQUE,
color text,
created_at timestamptz NOT NULL DEFAULT now()
);
-- Compétences
CREATE TABLE skills (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
category_id uuid NOT NULL REFERENCES categories(id) ON DELETE RESTRICT,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE(name, category_id)
);
-- Profils membres (liés à auth.users)
CREATE TABLE members (
id uuid PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
email text NOT NULL,
full_name text NOT NULL DEFAULT '',
role user_role NOT NULL DEFAULT 'member',
created_at timestamptz NOT NULL DEFAULT now()
);
-- Descriptifs des niveaux
CREATE TABLE level_descriptions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
level int2 NOT NULL CHECK (level BETWEEN 1 AND 4),
label text NOT NULL,
description text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
-- Niveaux de compétences par membre
CREATE TABLE skill_levels (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
member_id uuid NOT NULL REFERENCES members(id) ON DELETE CASCADE,
skill_id uuid NOT NULL REFERENCES skills(id) ON DELETE CASCADE,
level int2 NOT NULL CHECK (level BETWEEN 1 AND 4),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE(member_id, skill_id)
);
-- Historique des changements
CREATE TABLE skill_history (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
member_id uuid NOT NULL REFERENCES members(id) ON DELETE CASCADE,
skill_id uuid NOT NULL REFERENCES skills(id) ON DELETE CASCADE,
old_level int2,
new_level int2 NOT NULL,
changed_by uuid NOT NULL REFERENCES members(id),
created_at timestamptz NOT NULL DEFAULT now()
);
-- Invitations
CREATE TABLE invitations (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
email text NOT NULL,
token text NOT NULL UNIQUE,
role user_role NOT NULL DEFAULT 'member',
invited_by uuid NOT NULL REFERENCES members(id),
accepted bool NOT NULL DEFAULT false,
expires_at timestamptz NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
-- 3. INDEXES
CREATE INDEX idx_skills_category ON skills(category_id);
CREATE INDEX idx_skill_levels_member ON skill_levels(member_id);
CREATE INDEX idx_skill_levels_skill ON skill_levels(skill_id);
CREATE INDEX idx_skill_history_member ON skill_history(member_id);
CREATE INDEX idx_skill_history_skill ON skill_history(skill_id);
CREATE INDEX idx_skill_history_created ON skill_history(created_at DESC);
CREATE INDEX idx_invitations_token ON invitations(token);
CREATE INDEX idx_invitations_email ON invitations(email);
-- 4. SEED DATA
-- Catégories initiales
INSERT INTO categories (name, color) VALUES
('Réseau', '#3b82f6'),
('Système', '#10b981'),
('Cloud', '#f59e0b'),
('Sécurité', '#ef4444'),
('Base de données', '#8b5cf6'),
('Monitoring', '#ec4899'),
('Stockage', '#14b8a6');
-- Descriptifs des niveaux
INSERT INTO level_descriptions (level, label, description) VALUES
(1, 'Débutant', 'Connaissances théoriques, nécessite un accompagnement'),
(2, 'Intermédiaire', 'Réalise les tâches courantes en autonomie'),
(3, 'Avancé', 'Gère des situations complexes, forme les autres'),
(4, 'Expert', 'Référence technique, conçoit l''architecture');
-- 5. ROW LEVEL SECURITY
ALTER TABLE categories ENABLE ROW LEVEL SECURITY;
ALTER TABLE skills ENABLE ROW LEVEL SECURITY;
ALTER TABLE members ENABLE ROW LEVEL SECURITY;
ALTER TABLE level_descriptions ENABLE ROW LEVEL SECURITY;
ALTER TABLE skill_levels ENABLE ROW LEVEL SECURITY;
ALTER TABLE skill_history ENABLE ROW LEVEL SECURITY;
ALTER TABLE invitations ENABLE ROW LEVEL SECURITY;
-- Categories: tout le monde peut lire, seuls les admins écrivent
CREATE POLICY "categories_read_all" ON categories FOR SELECT USING (true);
CREATE POLICY "categories_write_admin" ON categories FOR INSERT WITH CHECK (
EXISTS (SELECT 1 FROM members WHERE id = auth.uid() AND role = 'admin')
);
CREATE POLICY "categories_update_admin" ON categories FOR UPDATE USING (
EXISTS (SELECT 1 FROM members WHERE id = auth.uid() AND role = 'admin')
);
CREATE POLICY "categories_delete_admin" ON categories FOR DELETE USING (
EXISTS (SELECT 1 FROM members WHERE id = auth.uid() AND role = 'admin')
);
-- Skills: tout le monde peut lire, seuls les admins écrivent
CREATE POLICY "skills_read_all" ON skills FOR SELECT USING (true);
CREATE POLICY "skills_write_admin" ON skills FOR INSERT WITH CHECK (
EXISTS (SELECT 1 FROM members WHERE id = auth.uid() AND role = 'admin')
);
CREATE POLICY "skills_update_admin" ON skills FOR UPDATE USING (
EXISTS (SELECT 1 FROM members WHERE id = auth.uid() AND role = 'admin')
);
CREATE POLICY "skills_delete_admin" ON skills FOR DELETE USING (
EXISTS (SELECT 1 FROM members WHERE id = auth.uid() AND role = 'admin')
);
-- Members: tout le monde peut lire, les admins peuvent modifier
CREATE POLICY "members_read_all" ON members FOR SELECT USING (true);
CREATE POLICY "members_insert_self" ON members FOR INSERT WITH CHECK (id = auth.uid());
CREATE POLICY "members_update_admin" ON members FOR UPDATE USING (
EXISTS (SELECT 1 FROM members WHERE id = auth.uid() AND role = 'admin')
);
CREATE POLICY "members_delete_admin" ON members FOR DELETE USING (
EXISTS (SELECT 1 FROM members WHERE id = auth.uid() AND role = 'admin')
);
-- Level descriptions: tout le monde peut lire
CREATE POLICY "level_descriptions_read_all" ON level_descriptions FOR SELECT USING (true);
-- Skill levels: tout le monde peut lire, seuls les admins modifient
CREATE POLICY "skill_levels_read_all" ON skill_levels FOR SELECT USING (true);
CREATE POLICY "skill_levels_insert_admin" ON skill_levels FOR INSERT WITH CHECK (
EXISTS (SELECT 1 FROM members WHERE id = auth.uid() AND role = 'admin')
);
CREATE POLICY "skill_levels_update_admin" ON skill_levels FOR UPDATE USING (
EXISTS (SELECT 1 FROM members WHERE id = auth.uid() AND role = 'admin')
);
CREATE POLICY "skill_levels_delete_admin" ON skill_levels FOR DELETE USING (
EXISTS (SELECT 1 FROM members WHERE id = auth.uid() AND role = 'admin')
);
-- Skill history: tout le monde peut lire, seuls les admins insèrent
CREATE POLICY "skill_history_read_all" ON skill_history FOR SELECT USING (true);
CREATE POLICY "skill_history_insert_admin" ON skill_history FOR INSERT WITH CHECK (
EXISTS (SELECT 1 FROM members WHERE id = auth.uid() AND role = 'admin')
);
-- Invitations: seuls les admins peuvent tout faire
CREATE POLICY "invitations_read_admin" ON invitations FOR SELECT USING (
EXISTS (SELECT 1 FROM members WHERE id = auth.uid() AND role = 'admin')
);
CREATE POLICY "invitations_insert_admin" ON invitations FOR INSERT WITH CHECK (
EXISTS (SELECT 1 FROM members WHERE id = auth.uid() AND role = 'admin')
);
CREATE POLICY "invitations_update_admin" ON invitations FOR UPDATE USING (
EXISTS (SELECT 1 FROM members WHERE id = auth.uid() AND role = 'admin')
);
CREATE POLICY "invitations_delete_admin" ON invitations FOR DELETE USING (
EXISTS (SELECT 1 FROM members WHERE id = auth.uid() AND role = 'admin')
);
-- 6. TRIGGER: Mise à jour automatique de updated_at
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS trigger AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER update_skill_levels_updated_at
BEFORE UPDATE ON skill_levels
FOR EACH ROW
EXECUTE FUNCTION update_updated_at();
-- 7. FONCTION: Créer automatiquement un membre lors de l'inscription
CREATE OR REPLACE FUNCTION handle_new_user()
RETURNS trigger AS $$
BEGIN
INSERT INTO public.members (id, email, full_name, role)
VALUES (
NEW.id,
NEW.email,
COALESCE(NEW.raw_user_meta_data->>'full_name', ''),
'member'
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
CREATE OR REPLACE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW
EXECUTE FUNCTION handle_new_user();
@@ -0,0 +1,80 @@
-- ============================================================
-- Migration 002: RLS WITH CHECK + GIN indexes + level_descriptions policies
-- ============================================================
-- 1. Ajout WITH CHECK sur les policies UPDATE existantes
-- (fonctionnellement identique au USING, mais explicite)
ALTER POLICY "categories_update_admin" ON categories
RENAME TO "categories_update_admin_old";
CREATE POLICY "categories_update_admin" ON categories FOR UPDATE USING (
EXISTS (SELECT 1 FROM members WHERE id = auth.uid() AND role = 'admin')
) WITH CHECK (
EXISTS (SELECT 1 FROM members WHERE id = auth.uid() AND role = 'admin')
);
DROP POLICY "categories_update_admin_old" ON categories;
ALTER POLICY "skills_update_admin" ON skills
RENAME TO "skills_update_admin_old";
CREATE POLICY "skills_update_admin" ON skills FOR UPDATE USING (
EXISTS (SELECT 1 FROM members WHERE id = auth.uid() AND role = 'admin')
) WITH CHECK (
EXISTS (SELECT 1 FROM members WHERE id = auth.uid() AND role = 'admin')
);
DROP POLICY "skills_update_admin_old" ON skills;
ALTER POLICY "members_update_admin" ON members
RENAME TO "members_update_admin_old";
CREATE POLICY "members_update_admin" ON members FOR UPDATE USING (
EXISTS (SELECT 1 FROM members WHERE id = auth.uid() AND role = 'admin')
) WITH CHECK (
EXISTS (SELECT 1 FROM members WHERE id = auth.uid() AND role = 'admin')
);
DROP POLICY "members_update_admin_old" ON members;
ALTER POLICY "skill_levels_update_admin" ON skill_levels
RENAME TO "skill_levels_update_admin_old";
CREATE POLICY "skill_levels_update_admin" ON skill_levels FOR UPDATE USING (
EXISTS (SELECT 1 FROM members WHERE id = auth.uid() AND role = 'admin')
) WITH CHECK (
EXISTS (SELECT 1 FROM members WHERE id = auth.uid() AND role = 'admin')
);
DROP POLICY "skill_levels_update_admin_old" ON skill_levels;
ALTER POLICY "invitations_update_admin" ON invitations
RENAME TO "invitations_update_admin_old";
CREATE POLICY "invitations_update_admin" ON invitations FOR UPDATE USING (
EXISTS (SELECT 1 FROM members WHERE id = auth.uid() AND role = 'admin')
) WITH CHECK (
EXISTS (SELECT 1 FROM members WHERE id = auth.uid() AND role = 'admin')
);
DROP POLICY "invitations_update_admin_old" ON invitations;
-- 2. Filtre expiration sur invitations_read_admin
ALTER POLICY "invitations_read_admin" ON invitations
RENAME TO "invitations_read_admin_old";
CREATE POLICY "invitations_read_admin" ON invitations FOR SELECT USING (
EXISTS (SELECT 1 FROM members WHERE id = auth.uid() AND role = 'admin')
AND expires_at > now()
);
DROP POLICY "invitations_read_admin_old" ON invitations;
-- 3. Policies d'écriture pour level_descriptions
CREATE POLICY "level_descriptions_insert_admin" ON level_descriptions FOR INSERT WITH CHECK (
EXISTS (SELECT 1 FROM members WHERE id = auth.uid() AND role = 'admin')
);
CREATE POLICY "level_descriptions_update_admin" ON level_descriptions FOR UPDATE USING (
EXISTS (SELECT 1 FROM members WHERE id = auth.uid() AND role = 'admin')
) WITH CHECK (
EXISTS (SELECT 1 FROM members WHERE id = auth.uid() AND role = 'admin')
);
CREATE POLICY "level_descriptions_delete_admin" ON level_descriptions FOR DELETE USING (
EXISTS (SELECT 1 FROM members WHERE id = auth.uid() AND role = 'admin')
);
-- 4. Index GIN pour full-text search (stemming français)
CREATE INDEX idx_skills_name_gin ON skills USING gin(to_tsvector('french', name));
CREATE INDEX idx_members_full_name_gin ON members USING gin(to_tsvector('french', full_name));
+7
View File
@@ -0,0 +1,7 @@
id,email,full_name,role,created_at
54f458ee-a8b9-4965-bfa6-f893adb10b5a,carole@equipe.local,Carole Lambert,member,2026-05-24 18:51:30.680837+00
6f420991-8c9d-47c0-82bb-437ea2626b8d,admin@equipe.local,Admin SysAdmin,admin,2026-05-24 18:31:09.701556+00
85ff3b30-be98-4a7e-b904-f34dda0a4fa2,ccado@free.fr,TopheC,member,2026-05-24 18:27:39.550418+00
976c2df6-f29d-4275-bb2e-d52e9d7a3fb0,bob@equipe.local,Bob Dupont,member,2026-05-24 18:51:30.411805+00
a8d8aa64-24fe-49bf-b468-514133262391,alice@equipe.local,Alice Martin,member,2026-05-24 18:30:23.785113+00
b1c37ba5-4cac-4753-9af9-ef8d1604b633,david@equipe.local,David Moreau,member,2026-05-24 18:51:30.905437+00
1 id email full_name role created_at
2 54f458ee-a8b9-4965-bfa6-f893adb10b5a carole@equipe.local Carole Lambert member 2026-05-24 18:51:30.680837+00
3 6f420991-8c9d-47c0-82bb-437ea2626b8d admin@equipe.local Admin SysAdmin admin 2026-05-24 18:31:09.701556+00
4 85ff3b30-be98-4a7e-b904-f34dda0a4fa2 ccado@free.fr TopheC member 2026-05-24 18:27:39.550418+00
5 976c2df6-f29d-4275-bb2e-d52e9d7a3fb0 bob@equipe.local Bob Dupont member 2026-05-24 18:51:30.411805+00
6 a8d8aa64-24fe-49bf-b468-514133262391 alice@equipe.local Alice Martin member 2026-05-24 18:30:23.785113+00
7 b1c37ba5-4cac-4753-9af9-ef8d1604b633 david@equipe.local David Moreau member 2026-05-24 18:51:30.905437+00
+7
View File
@@ -0,0 +1,7 @@
instance_id,id,aud,role,email,encrypted_password,email_confirmed_at,invited_at,confirmation_token,confirmation_sent_at,recovery_token,recovery_sent_at,email_change_token_new,email_change,email_change_sent_at,last_sign_in_at,raw_app_meta_data,raw_user_meta_data,is_super_admin,created_at,updated_at,phone,phone_confirmed_at,phone_change,phone_change_token,phone_change_sent_at,confirmed_at,email_change_token_current,email_change_confirm_status,banned_until,reauthentication_token,reauthentication_sent_at,is_sso_user,deleted_at,is_anonymous
00000000-0000-0000-0000-000000000000,54f458ee-a8b9-4965-bfa6-f893adb10b5a,authenticated,authenticated,carole@equipe.local,$2a$10$ocM8CZahprJiYq9LNNVn2uFbCPAvdIF2XnCpMWznsfPZNl2WfBVfe,2026-05-24 18:51:30.694855+00,,,,,,,,,,"{""provider"": ""email"", ""providers"": [""email""]}","{""email_verified"": true}",,2026-05-24 18:51:30.68138+00,2026-05-24 18:51:30.696387+00,,,,,,,,0,,,,false,,false
00000000-0000-0000-0000-000000000000,6f420991-8c9d-47c0-82bb-437ea2626b8d,authenticated,authenticated,admin@equipe.local,$2a$10$wKi0oKmHv/g8RfGAMnF66OhAb582pET48BHhUXdI8c.ILNRGbdkKq,2026-05-24 18:31:09.842402+00,,,,,,,,,2026-05-25 22:54:25.495781+00,"{""provider"": ""email"", ""providers"": [""email""]}","{""sub"": ""6f420991-8c9d-47c0-82bb-437ea2626b8d"", ""email"": ""admin@equipe.local"", ""full_name"": ""Admin SysAdmin"", ""email_verified"": true, ""phone_verified"": false}",,2026-05-24 18:31:09.711439+00,2026-05-26 20:13:01.738009+00,,,,,,,,0,,,,false,,false
00000000-0000-0000-0000-000000000000,85ff3b30-be98-4a7e-b904-f34dda0a4fa2,authenticated,authenticated,ccado@free.fr,$2a$10$/qyMBooYG37iycOSR3R7UeVKsBsODI5YKpUv5aLWNkFz3HaYzQwVa,2026-05-24 18:27:39.59039+00,,,,,,,,,2026-05-24 18:27:47.009959+00,"{""provider"": ""email"", ""providers"": [""email""]}","{""email_verified"": true}",,2026-05-24 18:27:39.552284+00,2026-05-24 18:27:47.08368+00,,,,,,,,0,,,,false,,false
00000000-0000-0000-0000-000000000000,976c2df6-f29d-4275-bb2e-d52e9d7a3fb0,authenticated,authenticated,bob@equipe.local,$2a$10$Ns9t4.1XR/N/Rxpvlp8.KeOnD0I52v5cyPPRKu.bcrnANJuh2BUCy,2026-05-24 18:51:30.445306+00,,,,,,,,,2026-05-25 22:53:32.10401+00,"{""provider"": ""email"", ""providers"": [""email""]}","{""email_verified"": true}",,2026-05-24 18:51:30.41263+00,2026-05-25 22:53:32.159755+00,,,,,,,,0,,,,false,,false
00000000-0000-0000-0000-000000000000,a8d8aa64-24fe-49bf-b468-514133262391,authenticated,authenticated,alice@equipe.local,$2a$10$3ikHB6c6GCg1cJFN1ffVi.JVdwQf/UmKCK0uef5a9Eqzl/hQqY/4a,2026-05-24 18:30:25.930066+00,,,,,,,,,,"{""provider"": ""email"", ""providers"": [""email""]}","{""full_name"": ""Alice Martin"", ""email_verified"": true}",,2026-05-24 18:30:23.859373+00,2026-05-24 18:30:26.196516+00,,,,,,,,0,,,,false,,false
00000000-0000-0000-0000-000000000000,b1c37ba5-4cac-4753-9af9-ef8d1604b633,authenticated,authenticated,david@equipe.local,$2a$10$g3MUw33xvC2UDkSANoAcDufjyh3QBB2k.RezQCyeH7XaY7BtDk00m,2026-05-24 18:51:30.922453+00,,,,,,,,,,"{""provider"": ""email"", ""providers"": [""email""]}","{""email_verified"": true}",,2026-05-24 18:51:30.906148+00,2026-05-24 18:51:30.923843+00,,,,,,,,0,,,,false,,false
1 instance_id id aud role email encrypted_password email_confirmed_at invited_at confirmation_token confirmation_sent_at recovery_token recovery_sent_at email_change_token_new email_change email_change_sent_at last_sign_in_at raw_app_meta_data raw_user_meta_data is_super_admin created_at updated_at phone phone_confirmed_at phone_change phone_change_token phone_change_sent_at confirmed_at email_change_token_current email_change_confirm_status banned_until reauthentication_token reauthentication_sent_at is_sso_user deleted_at is_anonymous
2 00000000-0000-0000-0000-000000000000 54f458ee-a8b9-4965-bfa6-f893adb10b5a authenticated authenticated carole@equipe.local $2a$10$ocM8CZahprJiYq9LNNVn2uFbCPAvdIF2XnCpMWznsfPZNl2WfBVfe 2026-05-24 18:51:30.694855+00 {"provider": "email", "providers": ["email"]} {"email_verified": true} 2026-05-24 18:51:30.68138+00 2026-05-24 18:51:30.696387+00 0 false false
3 00000000-0000-0000-0000-000000000000 6f420991-8c9d-47c0-82bb-437ea2626b8d authenticated authenticated admin@equipe.local $2a$10$wKi0oKmHv/g8RfGAMnF66OhAb582pET48BHhUXdI8c.ILNRGbdkKq 2026-05-24 18:31:09.842402+00 2026-05-25 22:54:25.495781+00 {"provider": "email", "providers": ["email"]} {"sub": "6f420991-8c9d-47c0-82bb-437ea2626b8d", "email": "admin@equipe.local", "full_name": "Admin SysAdmin", "email_verified": true, "phone_verified": false} 2026-05-24 18:31:09.711439+00 2026-05-26 20:13:01.738009+00 0 false false
4 00000000-0000-0000-0000-000000000000 85ff3b30-be98-4a7e-b904-f34dda0a4fa2 authenticated authenticated ccado@free.fr $2a$10$/qyMBooYG37iycOSR3R7UeVKsBsODI5YKpUv5aLWNkFz3HaYzQwVa 2026-05-24 18:27:39.59039+00 2026-05-24 18:27:47.009959+00 {"provider": "email", "providers": ["email"]} {"email_verified": true} 2026-05-24 18:27:39.552284+00 2026-05-24 18:27:47.08368+00 0 false false
5 00000000-0000-0000-0000-000000000000 976c2df6-f29d-4275-bb2e-d52e9d7a3fb0 authenticated authenticated bob@equipe.local $2a$10$Ns9t4.1XR/N/Rxpvlp8.KeOnD0I52v5cyPPRKu.bcrnANJuh2BUCy 2026-05-24 18:51:30.445306+00 2026-05-25 22:53:32.10401+00 {"provider": "email", "providers": ["email"]} {"email_verified": true} 2026-05-24 18:51:30.41263+00 2026-05-25 22:53:32.159755+00 0 false false
6 00000000-0000-0000-0000-000000000000 a8d8aa64-24fe-49bf-b468-514133262391 authenticated authenticated alice@equipe.local $2a$10$3ikHB6c6GCg1cJFN1ffVi.JVdwQf/UmKCK0uef5a9Eqzl/hQqY/4a 2026-05-24 18:30:25.930066+00 {"provider": "email", "providers": ["email"]} {"full_name": "Alice Martin", "email_verified": true} 2026-05-24 18:30:23.859373+00 2026-05-24 18:30:26.196516+00 0 false false
7 00000000-0000-0000-0000-000000000000 b1c37ba5-4cac-4753-9af9-ef8d1604b633 authenticated authenticated david@equipe.local $2a$10$g3MUw33xvC2UDkSANoAcDufjyh3QBB2k.RezQCyeH7XaY7BtDk00m 2026-05-24 18:51:30.922453+00 {"provider": "email", "providers": ["email"]} {"email_verified": true} 2026-05-24 18:51:30.906148+00 2026-05-24 18:51:30.923843+00 0 false false
+3
View File
@@ -0,0 +1,3 @@
{
"rewrites": [{ "source": "/(.*)", "destination": "/index.html" }]
}
+13
View File
@@ -0,0 +1,13 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
import path from 'path'
export default defineConfig({
plugins: [tailwindcss(), react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
})