Compare commits
21
Commits
3871be536c
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
61e9135070 | ||
|
|
161b6828e3 | ||
|
|
f68ddc6dc6 | ||
|
|
57285aeb17 | ||
|
|
339bdc2280 | ||
|
|
a5e1e3e206 | ||
|
|
d1a92299ec | ||
|
|
f1c39eaae2 | ||
|
|
a52b4baddc | ||
|
|
dc9fd58f66 | ||
|
|
65a6c50fec | ||
|
|
e6d2eee9ea | ||
|
|
24e08fca4f | ||
|
|
ac1b35b1d9 | ||
|
|
24afa9a8e8 | ||
|
|
2ee7decbfd | ||
|
|
e38a3df248 | ||
|
|
6da60a0d81 | ||
|
|
b6d0913b38 | ||
|
|
3e08535b5c | ||
|
|
a3b331ada6 |
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
*.local
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.agents
|
||||||
|
.dockerignore
|
||||||
@@ -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
|
||||||
Executable
+81
@@ -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
@@ -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*
|
||||||
@@ -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
@@ -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;"]
|
||||||
@@ -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).
|
||||||
|
|||||||
+25839
File diff suppressed because one or more lines are too long
@@ -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": {}
|
||||||
|
}
|
||||||
@@ -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
@@ -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>
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+14
@@ -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";
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+9249
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -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 |
@@ -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)
|
||||||
|
})
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
@@ -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;
|
||||||
@@ -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
@@ -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 |
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import {
|
||||||
|
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend,
|
||||||
|
} from 'recharts'
|
||||||
|
|
||||||
|
const levelLabels = {
|
||||||
|
1: { label: 'Débutant', color: '#9ca3af' },
|
||||||
|
2: { label: 'Intermédiaire', color: '#60a5fa' },
|
||||||
|
3: { label: 'Avancé', color: '#fbbf24' },
|
||||||
|
4: { label: 'Expert', color: '#34d399' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BarChartView({
|
||||||
|
categories, skills: allSkills, members, levels, filterCat,
|
||||||
|
onBarClick,
|
||||||
|
}) {
|
||||||
|
const cats = filterCat === 'all' ? categories : categories.filter((c) => c.id === filterCat)
|
||||||
|
|
||||||
|
const data = cats.map((cat) => {
|
||||||
|
const catSkills = allSkills.filter((s) => s.category_id === cat.id)
|
||||||
|
const buckets = { 1: 0, 2: 0, 3: 0, 4: 0 }
|
||||||
|
members.forEach((m) => {
|
||||||
|
catSkills.forEach((s) => {
|
||||||
|
const key = `${m.id}-${s.id}`
|
||||||
|
const lvl = levels[key]?.level
|
||||||
|
if (lvl) buckets[lvl]++
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
name: cat.name,
|
||||||
|
color: cat.color,
|
||||||
|
id: cat.id,
|
||||||
|
...buckets,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return <div className="p-8 text-center text-gray-400">Aucune donnée à afficher</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClick(entry, _index, level) {
|
||||||
|
if (onBarClick && entry?.id) {
|
||||||
|
onBarClick(entry.id, level)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-950 rounded-lg p-4">
|
||||||
|
<ResponsiveContainer width="100%" height={400}>
|
||||||
|
<BarChart data={data} layout="vertical" margin={{ top: 20, right: 30, left: 80, bottom: 5 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" horizontal={false} />
|
||||||
|
<XAxis type="number" stroke="var(--foreground)" tick={{ fontSize: 12 }} />
|
||||||
|
<YAxis type="category" dataKey="name" stroke="var(--foreground)" tick={{ fontSize: 12 }} width={100} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
background: 'var(--background)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius)',
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Legend
|
||||||
|
payload={[1, 2, 3, 4].map((l) => ({
|
||||||
|
value: `${l} - ${levelLabels[l].label}`,
|
||||||
|
type: 'rect',
|
||||||
|
color: levelLabels[l].color,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
{[1, 2, 3, 4].map((lvl) => (
|
||||||
|
<Bar
|
||||||
|
key={lvl}
|
||||||
|
dataKey={lvl}
|
||||||
|
stackId="a"
|
||||||
|
fill={levelLabels[lvl].color}
|
||||||
|
name={`Niveau ${lvl}`}
|
||||||
|
cursor="pointer"
|
||||||
|
onClick={(entry) => handleClick(entry, null, lvl)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,251 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { forceSimulation, forceLink, forceManyBody, forceCenter, forceCollide } from 'd3-force'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
|
export default function GraphView({
|
||||||
|
categories, skills: allSkills, levels,
|
||||||
|
filteredSkills, filteredMembers,
|
||||||
|
}) {
|
||||||
|
const canvasRef = useRef(null)
|
||||||
|
const [aggregated, setAggregated] = useState(false)
|
||||||
|
const [selectedNode, setSelectedNode] = useState(null)
|
||||||
|
const width = 900
|
||||||
|
const height = 600
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const canvas = canvasRef.current
|
||||||
|
if (!canvas) return
|
||||||
|
const ctx = canvas.getContext('2d')
|
||||||
|
if (!ctx) return
|
||||||
|
|
||||||
|
const catMap = {}
|
||||||
|
categories.forEach((c) => { catMap[c.id] = c })
|
||||||
|
|
||||||
|
const nodes = []
|
||||||
|
const links = []
|
||||||
|
const nodeMap = new Map()
|
||||||
|
|
||||||
|
filteredMembers.forEach((m) => {
|
||||||
|
const node = { id: m.id, type: 'member', label: m.full_name || m.email, r: 10, x: width / 2, y: height / 2 }
|
||||||
|
nodes.push(node)
|
||||||
|
nodeMap.set(m.id, node)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (aggregated) {
|
||||||
|
categories
|
||||||
|
.filter((cat) => filteredSkills.length === 0 || filteredSkills.some((s) => s.category_id === cat.id))
|
||||||
|
.forEach((cat) => {
|
||||||
|
const nid = `cat-${cat.id}`
|
||||||
|
const node = { id: nid, type: 'category', label: cat.name, r: 14, color: cat.color, catId: cat.id, x: width / 2, y: height / 2 }
|
||||||
|
nodes.push(node)
|
||||||
|
nodeMap.set(nid, node)
|
||||||
|
|
||||||
|
filteredMembers.forEach((m) => {
|
||||||
|
const catSkillIds = allSkills.filter((s) => s.category_id === cat.id).map((s) => s.id)
|
||||||
|
let total = 0
|
||||||
|
let count = 0
|
||||||
|
catSkillIds.forEach((sid) => {
|
||||||
|
const key = `${m.id}-${sid}`
|
||||||
|
const lvl = levels[key]?.level
|
||||||
|
if (lvl) { total += lvl; count++ }
|
||||||
|
})
|
||||||
|
if (count > 0) {
|
||||||
|
links.push({ source: m.id, target: nid, strength: total / count / 4 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
filteredSkills.forEach((s) => {
|
||||||
|
const nid = `skill-${s.id}`
|
||||||
|
const node = { id: nid, type: 'skill', label: s.name, r: 8, color: catMap[s.category_id]?.color || '#888', skillId: s.id, x: width / 2, y: height / 2 }
|
||||||
|
nodes.push(node)
|
||||||
|
nodeMap.set(nid, node)
|
||||||
|
})
|
||||||
|
|
||||||
|
filteredMembers.forEach((m) => {
|
||||||
|
filteredSkills.forEach((s) => {
|
||||||
|
const key = `${m.id}-${s.id}`
|
||||||
|
const lvl = levels[key]?.level
|
||||||
|
if (lvl) {
|
||||||
|
links.push({ source: m.id, target: `skill-${s.id}`, strength: lvl / 4 })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nodes.length === 0) return
|
||||||
|
|
||||||
|
const dpi = window.devicePixelRatio || 1
|
||||||
|
canvas.width = width * dpi
|
||||||
|
canvas.height = height * dpi
|
||||||
|
ctx.scale(dpi, dpi)
|
||||||
|
|
||||||
|
const borderColor = getComputedStyle(canvas).getPropertyValue('--border').trim() || '#e5e7eb'
|
||||||
|
const fgColor = getComputedStyle(canvas).getPropertyValue('--foreground').trim() || '#000'
|
||||||
|
|
||||||
|
const simulation = forceSimulation(nodes)
|
||||||
|
.force('link', forceLink(links).id((d) => d.id).distance(120).strength((d) => d.strength || 0.3))
|
||||||
|
.force('charge', forceManyBody().strength(-150))
|
||||||
|
.force('center', forceCenter(width / 2, height / 2))
|
||||||
|
.force('collide', forceCollide(20))
|
||||||
|
.on('tick', ticked)
|
||||||
|
|
||||||
|
function ticked() {
|
||||||
|
ctx.clearRect(0, 0, width, height)
|
||||||
|
|
||||||
|
ctx.strokeStyle = borderColor
|
||||||
|
ctx.lineWidth = 1
|
||||||
|
links.forEach((l) => {
|
||||||
|
const s = l.strength || 0.3
|
||||||
|
ctx.globalAlpha = s * 0.5 + 0.15
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.moveTo(l.source.x, l.source.y)
|
||||||
|
ctx.lineTo(l.target.x, l.target.y)
|
||||||
|
ctx.stroke()
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx.globalAlpha = 1
|
||||||
|
nodes.forEach((n) => {
|
||||||
|
const isSel = selectedNode === n.id
|
||||||
|
const isNeighbor = isSel
|
||||||
|
? links.some((l) => {
|
||||||
|
const sid = typeof l.source === 'object' ? l.source.id : l.source
|
||||||
|
const tid = typeof l.target === 'object' ? l.target.id : l.target
|
||||||
|
return (sid === selectedNode && tid === n.id) || (tid === selectedNode && sid === n.id)
|
||||||
|
})
|
||||||
|
: false
|
||||||
|
|
||||||
|
ctx.globalAlpha = selectedNode && !isSel && !isNeighbor ? 0.12 : 1
|
||||||
|
|
||||||
|
if (n.type === 'member') {
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(n.x, n.y, n.r, 0, 2 * Math.PI)
|
||||||
|
ctx.fillStyle = '#6366f1'
|
||||||
|
ctx.fill()
|
||||||
|
if (isSel) {
|
||||||
|
ctx.strokeStyle = '#fff'
|
||||||
|
ctx.lineWidth = 2
|
||||||
|
ctx.stroke()
|
||||||
|
}
|
||||||
|
} else if (n.type === 'category') {
|
||||||
|
ctx.beginPath()
|
||||||
|
ctx.arc(n.x, n.y, n.r, 0, 2 * Math.PI)
|
||||||
|
ctx.fillStyle = n.color || '#888'
|
||||||
|
ctx.fill()
|
||||||
|
if (isSel) {
|
||||||
|
ctx.strokeStyle = '#fff'
|
||||||
|
ctx.lineWidth = 2
|
||||||
|
ctx.stroke()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const s = 6
|
||||||
|
ctx.fillStyle = n.color || '#888'
|
||||||
|
ctx.fillRect(n.x - s / 2, n.y - s / 2, s, s)
|
||||||
|
if (isSel) {
|
||||||
|
ctx.strokeStyle = '#fff'
|
||||||
|
ctx.lineWidth = 2
|
||||||
|
ctx.strokeRect(n.x - s / 2, n.y - s / 2, s, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.fillStyle = fgColor
|
||||||
|
ctx.font = '10px sans-serif'
|
||||||
|
ctx.textAlign = 'center'
|
||||||
|
ctx.fillText(n.label, n.x, n.y + n.r + 12)
|
||||||
|
})
|
||||||
|
|
||||||
|
ctx.globalAlpha = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
let dragNode = null
|
||||||
|
|
||||||
|
function getCanvasPos(e) {
|
||||||
|
const rect = canvas.getBoundingClientRect()
|
||||||
|
return {
|
||||||
|
x: (e.clientX - rect.left) * (width / rect.width),
|
||||||
|
y: (e.clientY - rect.top) * (height / rect.height),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function findHit(px, py) {
|
||||||
|
for (let i = nodes.length - 1; i >= 0; i--) {
|
||||||
|
const n = nodes[i]
|
||||||
|
const dx = px - n.x
|
||||||
|
const dy = py - n.y
|
||||||
|
if (dx * dx + dy * dy <= (n.r + 8) * (n.r + 8)) return n
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerDown(e) {
|
||||||
|
const pos = getCanvasPos(e)
|
||||||
|
const hit = findHit(pos.x, pos.y)
|
||||||
|
if (hit) {
|
||||||
|
dragNode = hit
|
||||||
|
setSelectedNode(hit.id)
|
||||||
|
hit.fx = hit.x
|
||||||
|
hit.fy = hit.y
|
||||||
|
simulation.alphaTarget(0.3).restart()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerMove(e) {
|
||||||
|
if (!dragNode) return
|
||||||
|
const pos = getCanvasPos(e)
|
||||||
|
dragNode.fx = Math.max(0, Math.min(width, pos.x))
|
||||||
|
dragNode.fy = Math.max(0, Math.min(height, pos.y))
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerUp() {
|
||||||
|
if (dragNode) {
|
||||||
|
dragNode.fx = null
|
||||||
|
dragNode.fy = null
|
||||||
|
dragNode = null
|
||||||
|
simulation.alphaTarget(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
canvas.addEventListener('pointerdown', onPointerDown)
|
||||||
|
window.addEventListener('pointermove', onPointerMove)
|
||||||
|
window.addEventListener('pointerup', onPointerUp)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
simulation.stop()
|
||||||
|
canvas.removeEventListener('pointerdown', onPointerDown)
|
||||||
|
window.removeEventListener('pointermove', onPointerMove)
|
||||||
|
window.removeEventListener('pointerup', onPointerUp)
|
||||||
|
}
|
||||||
|
}, [filteredSkills, filteredMembers, levels, categories, aggregated, selectedNode, allSkills])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||||
|
<div className="flex items-center gap-3 text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<span className="w-3 h-3 rounded-full bg-indigo-500 inline-block" /> Membre
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<span className="w-3 h-3 rounded-sm bg-gray-400 inline-block" /> Compétence
|
||||||
|
</span>
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<span className="w-3 h-3 rounded-full border border-current inline-block" /> Catégorie
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button variant="outline" size="sm" onClick={() => setAggregated(!aggregated)}>
|
||||||
|
{aggregated ? 'Détailler les compétences' : 'Agréger par catégorie'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white dark:bg-gray-950 rounded-lg overflow-hidden relative">
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
className="block w-full touch-none"
|
||||||
|
style={{ height: `${height}px`, cursor: 'grab' }}
|
||||||
|
/>
|
||||||
|
<div className="absolute bottom-3 left-1/2 -translate-x-1/2 text-xs text-gray-400 pointer-events-none">
|
||||||
|
Cliquez sur un nœud pour le sélectionner • Glissez pour déplacer
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { ChevronDown, ChevronRight, ListCollapse } from 'lucide-react'
|
||||||
|
import { SkillLevelSelect } from '@/components/SkillLevelBadge'
|
||||||
|
|
||||||
|
const heatColors = {
|
||||||
|
0: 'bg-gray-50 dark:bg-gray-900',
|
||||||
|
1: 'bg-gray-200 dark:bg-gray-700',
|
||||||
|
2: 'bg-blue-200 dark:bg-blue-900',
|
||||||
|
3: 'bg-amber-200 dark:bg-amber-900',
|
||||||
|
4: 'bg-green-200 dark:bg-green-900',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function HeatmapView({
|
||||||
|
categories, levels,
|
||||||
|
isAdmin, currentUserId,
|
||||||
|
editing, onEdit, onUpdate, onCancel,
|
||||||
|
filteredSkills, visibleMembers,
|
||||||
|
}) {
|
||||||
|
const [collapsed, setCollapsed] = useState(new Set())
|
||||||
|
|
||||||
|
const grouped = categories
|
||||||
|
.map((cat) => ({
|
||||||
|
...cat,
|
||||||
|
catSkills: filteredSkills.filter((s) => s.category_id === cat.id),
|
||||||
|
}))
|
||||||
|
.filter((g) => g.catSkills.length > 0)
|
||||||
|
|
||||||
|
const allCollapsed = grouped.every((g) => collapsed.has(g.id))
|
||||||
|
|
||||||
|
function toggleCategory(catId) {
|
||||||
|
setCollapsed((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(catId)) next.delete(catId)
|
||||||
|
else next.add(catId)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAll() {
|
||||||
|
if (allCollapsed) {
|
||||||
|
setCollapsed(new Set())
|
||||||
|
} else {
|
||||||
|
setCollapsed(new Set(grouped.map((g) => g.id)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (visibleMembers.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="p-8 text-center text-gray-400">
|
||||||
|
Aucun résultat
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-auto">
|
||||||
|
<table className="w-full border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="text-left p-2 bg-gray-100 dark:bg-gray-800 border dark:border-gray-700 sticky left-0 z-10 min-w-[180px]">
|
||||||
|
<button
|
||||||
|
className="inline-flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 mr-2"
|
||||||
|
onClick={toggleAll}
|
||||||
|
title={allCollapsed ? 'Tout dérouler' : 'Tout replier'}
|
||||||
|
>
|
||||||
|
<ListCollapse className={`h-3.5 w-3.5 transition-transform ${allCollapsed ? '' : 'rotate-180'}`} />
|
||||||
|
</button>
|
||||||
|
Compétence
|
||||||
|
</th>
|
||||||
|
{visibleMembers.map((m) => (
|
||||||
|
<th key={m.id} className="p-2 bg-gray-100 dark:bg-gray-800 border dark:border-gray-700 text-sm text-center min-w-[120px]">
|
||||||
|
{m.full_name || m.email}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{grouped.map((g) => {
|
||||||
|
const isCollapsed = collapsed.has(g.id)
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<tr key={g.id} className="bg-gray-50 dark:bg-gray-800/50 hover:bg-gray-100 dark:hover:bg-gray-800">
|
||||||
|
<td className="p-2 border dark:border-gray-700 font-semibold text-sm sticky left-0 bg-gray-50 dark:bg-gray-800/50 cursor-pointer" onClick={() => toggleCategory(g.id)}>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
{isCollapsed ? <ChevronRight className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||||
|
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: g.color }} />
|
||||||
|
{g.name}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
{visibleMembers.map((m) => {
|
||||||
|
const dotColors = ['bg-gray-200', 'bg-blue-200', 'bg-amber-200', 'bg-green-200']
|
||||||
|
const counts = [0, 0, 0, 0]
|
||||||
|
g.catSkills.forEach((s) => {
|
||||||
|
const lvl = levels[`${m.id}-${s.id}`]?.level
|
||||||
|
if (lvl) counts[lvl - 1]++
|
||||||
|
})
|
||||||
|
return (
|
||||||
|
<td key={m.id} className="p-2 border dark:border-gray-700 text-center bg-gray-50 dark:bg-gray-800/50">
|
||||||
|
<span className="inline-flex items-center gap-2 text-xs">
|
||||||
|
{counts.map((c, i) =>
|
||||||
|
c > 0 ? (
|
||||||
|
<span key={i} className="inline-flex items-center gap-0.5">
|
||||||
|
<span className={'inline-block w-2.5 h-2.5 rounded-full ' + dotColors[i]} />
|
||||||
|
<span className="font-semibold">{c}</span>
|
||||||
|
</span>
|
||||||
|
) : null
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
{!isCollapsed && g.catSkills.map((s) => (
|
||||||
|
<tr key={s.id} className="hover:bg-gray-50 dark:hover:bg-gray-800/50">
|
||||||
|
<td className="p-2 border dark:border-gray-700 font-medium sticky left-0 bg-white dark:bg-gray-950">
|
||||||
|
<span className="text-sm pl-6">{s.name}</span>
|
||||||
|
</td>
|
||||||
|
{visibleMembers.map((m) => {
|
||||||
|
const key = `${m.id}-${s.id}`
|
||||||
|
const level = levels[key]
|
||||||
|
const lvl = level?.level || 0
|
||||||
|
const canEditCell = isAdmin || currentUserId === m.id
|
||||||
|
const isEditing = editing === key
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
key={m.id}
|
||||||
|
className={`p-2 border dark:border-gray-700 text-center ${heatColors[lvl]} ${currentUserId === m.id ? 'ring-2 ring-blue-400 dark:ring-blue-600 ring-inset' : ''}`}
|
||||||
|
>
|
||||||
|
{isEditing && canEditCell ? (
|
||||||
|
<SkillLevelSelect
|
||||||
|
value={level?.level || 1}
|
||||||
|
onChange={(v) => onUpdate(m.id, s.id, v)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="text-sm font-medium cursor-pointer"
|
||||||
|
onClick={() => canEditCell && onEdit(key)}
|
||||||
|
>
|
||||||
|
{lvl || '—'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isEditing && canEditCell && (
|
||||||
|
<button className="ml-1 text-xs text-red-500" onClick={onCancel}>✕</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import {
|
||||||
|
RadarChart, PolarGrid, PolarAngleAxis, PolarRadiusAxis, Radar, Tooltip, ResponsiveContainer, Legend,
|
||||||
|
} from 'recharts'
|
||||||
|
|
||||||
|
export default function RadarView({
|
||||||
|
categories, skills: allSkills, members, levels, filterMember,
|
||||||
|
}) {
|
||||||
|
const [selectedMember, setSelectedMember] = useState(filterMember !== 'all' ? filterMember : (members[0]?.id || ''))
|
||||||
|
|
||||||
|
function getCategoryAvg(catId) {
|
||||||
|
const catSkills = allSkills.filter((s) => s.category_id === catId)
|
||||||
|
if (catSkills.length === 0) return 0
|
||||||
|
let total = 0
|
||||||
|
let count = 0
|
||||||
|
members.forEach((m) => {
|
||||||
|
catSkills.forEach((s) => {
|
||||||
|
const key = `${m.id}-${s.id}`
|
||||||
|
const lvl = levels[key]?.level
|
||||||
|
if (lvl) { total += lvl; count++ }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return count > 0 ? +(total / count).toFixed(1) : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMemberAvg(catId, memberId) {
|
||||||
|
const catSkills = allSkills.filter((s) => s.category_id === catId)
|
||||||
|
if (catSkills.length === 0) return 0
|
||||||
|
let total = 0
|
||||||
|
let count = 0
|
||||||
|
catSkills.forEach((s) => {
|
||||||
|
const key = `${memberId}-${s.id}`
|
||||||
|
const lvl = levels[key]?.level
|
||||||
|
if (lvl) { total += lvl; count++ }
|
||||||
|
})
|
||||||
|
return count > 0 ? +(total / count).toFixed(1) : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = categories.map((cat) => {
|
||||||
|
const teamAvg = getCategoryAvg(cat.id)
|
||||||
|
const memberAvg = selectedMember ? getMemberAvg(cat.id, selectedMember) : 0
|
||||||
|
return {
|
||||||
|
category: cat.name,
|
||||||
|
color: cat.color,
|
||||||
|
teamAvg,
|
||||||
|
memberAvg,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const selectedMemberName = members.find((m) => m.id === selectedMember)?.full_name || 'Membre'
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return <div className="p-8 text-center text-gray-400">Aucune donnée à afficher</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-950 rounded-lg p-4">
|
||||||
|
<div className="flex items-center gap-4 mb-4">
|
||||||
|
<label className="text-sm text-gray-600 dark:text-gray-400">Membre :</label>
|
||||||
|
<select
|
||||||
|
className="border rounded px-2 py-1 text-sm dark:bg-gray-800 dark:border-gray-700"
|
||||||
|
value={selectedMember}
|
||||||
|
onChange={(e) => setSelectedMember(e.target.value)}
|
||||||
|
>
|
||||||
|
{members.map((m) => (
|
||||||
|
<option key={m.id} value={m.id}>{m.full_name || m.email}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ResponsiveContainer width="100%" height={450}>
|
||||||
|
<RadarChart data={data}>
|
||||||
|
<PolarGrid stroke="var(--border)" />
|
||||||
|
<PolarAngleAxis dataKey="category" stroke="var(--foreground)" tick={{ fontSize: 11 }} />
|
||||||
|
<PolarRadiusAxis domain={[0, 4]} tickCount={5} stroke="var(--border)" tick={{ fontSize: 10 }} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
background: 'var(--background)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius)',
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Radar
|
||||||
|
name="Moyenne équipe"
|
||||||
|
dataKey="teamAvg"
|
||||||
|
stroke="var(--muted-foreground)"
|
||||||
|
fill="var(--muted-foreground)"
|
||||||
|
fillOpacity={0.1}
|
||||||
|
strokeDasharray="4 4"
|
||||||
|
/>
|
||||||
|
<Radar
|
||||||
|
name={selectedMemberName}
|
||||||
|
dataKey="memberAvg"
|
||||||
|
stroke="var(--foreground)"
|
||||||
|
fill="var(--foreground)"
|
||||||
|
fillOpacity={0.15}
|
||||||
|
/>
|
||||||
|
<Legend />
|
||||||
|
</RadarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
import {
|
||||||
|
ScatterChart, Scatter, XAxis, YAxis, ZAxis, CartesianGrid,
|
||||||
|
Tooltip, ResponsiveContainer, Legend,
|
||||||
|
} from 'recharts'
|
||||||
|
|
||||||
|
export default function ScatterView({
|
||||||
|
members, levels, categories,
|
||||||
|
filterMember,
|
||||||
|
filteredSkills, onMemberSelect,
|
||||||
|
}) {
|
||||||
|
const data = []
|
||||||
|
const catGaps = {}
|
||||||
|
let xPos = 0
|
||||||
|
|
||||||
|
categories.forEach((cat) => {
|
||||||
|
const catSkills = filteredSkills.filter((s) => s.category_id === cat.id)
|
||||||
|
if (catSkills.length === 0) return
|
||||||
|
xPos += 1
|
||||||
|
catGaps[cat.id] = { start: xPos, end: xPos + catSkills.length - 1 }
|
||||||
|
catSkills.forEach((s) => {
|
||||||
|
const x = xPos++
|
||||||
|
members.forEach((m) => {
|
||||||
|
if (filterMember !== 'all' && m.id !== filterMember) return
|
||||||
|
const key = `${m.id}-${s.id}`
|
||||||
|
const lvl = levels[key]?.level
|
||||||
|
if (!lvl) return
|
||||||
|
data.push({
|
||||||
|
x,
|
||||||
|
y: lvl + (Math.random() - 0.5) * 0.3,
|
||||||
|
memberName: m.full_name || m.email,
|
||||||
|
skillName: s.name,
|
||||||
|
level: lvl,
|
||||||
|
category: cat.name,
|
||||||
|
categoryColor: cat.color,
|
||||||
|
memberId: m.id,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const ticks = categories
|
||||||
|
.filter((cat) => catGaps[cat.id])
|
||||||
|
.map((cat) => ({
|
||||||
|
value: (catGaps[cat.id].start + catGaps[cat.id].end) / 2,
|
||||||
|
label: cat.name,
|
||||||
|
}))
|
||||||
|
|
||||||
|
if (data.length === 0) {
|
||||||
|
return <div className="p-8 text-center text-gray-400">Aucune donnée à afficher</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-950 rounded-lg p-4">
|
||||||
|
<ResponsiveContainer width="100%" height={500}>
|
||||||
|
<ScatterChart margin={{ top: 20, right: 20, bottom: 60, left: 40 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
||||||
|
<XAxis
|
||||||
|
type="number"
|
||||||
|
dataKey="x"
|
||||||
|
domain={[0, 'dataMax']}
|
||||||
|
ticks={ticks.map((t) => t.value)}
|
||||||
|
tickFormatter={(v) => ticks.find((t) => t.value === v)?.label || ''}
|
||||||
|
stroke="var(--foreground)"
|
||||||
|
tick={{ fontSize: 12 }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
type="number"
|
||||||
|
domain={[0.5, 4.5]}
|
||||||
|
ticks={[1, 2, 3, 4]}
|
||||||
|
tickFormatter={(v) => ['', '1 - Débutant', '2 - Intermédiaire', '3 - Avancé', '4 - Expert'][v]}
|
||||||
|
stroke="var(--foreground)"
|
||||||
|
tick={{ fontSize: 12 }}
|
||||||
|
/>
|
||||||
|
<ZAxis range={[60, 60]} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
background: 'var(--background)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius)',
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
formatter={(val, name) => {
|
||||||
|
if (name === 'y') return null
|
||||||
|
return [val, name]
|
||||||
|
}}
|
||||||
|
labelFormatter={() => ''}
|
||||||
|
content={({ active, payload }) => {
|
||||||
|
if (!active || !payload?.[0]) return null
|
||||||
|
const d = payload[0].payload
|
||||||
|
return (
|
||||||
|
<div className="bg-white dark:bg-gray-800 border rounded-lg p-3 shadow-lg text-sm space-y-1">
|
||||||
|
<p className="font-medium">{d.memberName}</p>
|
||||||
|
<p>{d.skillName}</p>
|
||||||
|
<p>Niveau : <strong>{d.level}</strong></p>
|
||||||
|
<p className="text-xs" style={{ color: d.categoryColor }}>{d.category}</p>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Legend
|
||||||
|
payload={categories.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
value: c.name,
|
||||||
|
type: 'circle',
|
||||||
|
color: c.color,
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
{categories.map((cat) => {
|
||||||
|
const catData = data.filter((d) => d.category === cat.name)
|
||||||
|
return (
|
||||||
|
<Scatter
|
||||||
|
key={cat.id}
|
||||||
|
name={cat.name}
|
||||||
|
data={catData}
|
||||||
|
fill={cat.color}
|
||||||
|
stroke="none"
|
||||||
|
onClick={(point) => onMemberSelect?.(point.memberId)}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ScatterChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
export function SkillMatrixFilters({ categories, members, filterCat, filterMember, filterMinLevel, onFilterChange, hideMember }) {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-4 flex-wrap">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="text-sm text-gray-600 dark:text-gray-400">Catégorie :</label>
|
||||||
|
<select
|
||||||
|
className="border rounded px-2 py-1 text-sm dark:bg-gray-800 dark:border-gray-700"
|
||||||
|
value={filterCat}
|
||||||
|
onChange={(e) => onFilterChange('cat', e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="all">Toutes</option>
|
||||||
|
{categories.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>{c.name}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{!hideMember && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="text-sm text-gray-600 dark:text-gray-400">Membre :</label>
|
||||||
|
<select
|
||||||
|
className="border rounded px-2 py-1 text-sm dark:bg-gray-800 dark:border-gray-700"
|
||||||
|
value={filterMember}
|
||||||
|
onChange={(e) => onFilterChange('member', e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="all">Tous</option>
|
||||||
|
{members.map((m) => (
|
||||||
|
<option key={m.id} value={m.id}>{m.full_name || m.email}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="text-sm text-gray-600 dark:text-gray-400">Niveau min. :</label>
|
||||||
|
<select
|
||||||
|
className="border rounded px-2 py-1 text-sm dark:bg-gray-800 dark:border-gray-700"
|
||||||
|
value={filterMinLevel}
|
||||||
|
onChange={(e) => onFilterChange('minLevel', Number(e.target.value))}
|
||||||
|
>
|
||||||
|
<option value={0}>Aucun</option>
|
||||||
|
{[1, 2, 3, 4].map((l) => <option key={l} value={l}>{l}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { ChevronDown, ChevronRight, ListCollapse } from 'lucide-react'
|
||||||
|
import { SkillLevelBadge, SkillLevelSelect } from '@/components/SkillLevelBadge'
|
||||||
|
|
||||||
|
export function SkillMatrixTable({ categories, skills, members, levels, isAdmin, currentUserId, editing, onEdit, onUpdate, onCancel }) {
|
||||||
|
const [collapsed, setCollapsed] = useState(new Set())
|
||||||
|
|
||||||
|
const grouped = categories
|
||||||
|
.map((cat) => ({
|
||||||
|
...cat,
|
||||||
|
catSkills: skills.filter((s) => s.category_id === cat.id),
|
||||||
|
}))
|
||||||
|
.filter((g) => g.catSkills.length > 0)
|
||||||
|
|
||||||
|
const allCollapsed = grouped.every((g) => collapsed.has(g.id))
|
||||||
|
|
||||||
|
function toggleCategory(catId) {
|
||||||
|
setCollapsed((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(catId)) next.delete(catId)
|
||||||
|
else next.add(catId)
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleAll() {
|
||||||
|
if (allCollapsed) {
|
||||||
|
setCollapsed(new Set())
|
||||||
|
} else {
|
||||||
|
setCollapsed(new Set(grouped.map((g) => g.id)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (members.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="p-8 text-center text-gray-400">
|
||||||
|
Aucun résultat
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="overflow-auto">
|
||||||
|
<table className="w-full border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th className="text-left p-2 bg-gray-100 dark:bg-gray-800 border dark:border-gray-700 sticky left-0 z-10 min-w-[180px]">
|
||||||
|
<button
|
||||||
|
className="inline-flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 mr-2"
|
||||||
|
onClick={toggleAll}
|
||||||
|
title={allCollapsed ? 'Tout dérouler' : 'Tout replier'}
|
||||||
|
>
|
||||||
|
<ListCollapse className={`h-3.5 w-3.5 transition-transform ${allCollapsed ? '' : 'rotate-180'}`} />
|
||||||
|
</button>
|
||||||
|
Compétence
|
||||||
|
</th>
|
||||||
|
{members.map((m) => (
|
||||||
|
<th key={m.id} className="p-2 bg-gray-100 dark:bg-gray-800 border dark:border-gray-700 text-sm text-center min-w-[120px]">
|
||||||
|
{m.full_name || m.email}
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{grouped.map((g) => {
|
||||||
|
const isCollapsed = collapsed.has(g.id)
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<tr key={g.id} className="bg-gray-50 dark:bg-gray-800/50 hover:bg-gray-100 dark:hover:bg-gray-800">
|
||||||
|
<td className="p-2 border dark:border-gray-700 font-semibold text-sm sticky left-0 bg-gray-50 dark:bg-gray-800/50 cursor-pointer" onClick={() => toggleCategory(g.id)}>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
{isCollapsed ? <ChevronRight className="h-4 w-4" /> : <ChevronDown className="h-4 w-4" />}
|
||||||
|
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: g.color }} />
|
||||||
|
{g.name}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
{members.map((m) => {
|
||||||
|
const dotColors = ['bg-gray-200', 'bg-blue-200', 'bg-amber-200', 'bg-green-200']
|
||||||
|
const counts = [0, 0, 0, 0]
|
||||||
|
g.catSkills.forEach((s) => {
|
||||||
|
const lvl = levels[`${m.id}-${s.id}`]?.level
|
||||||
|
if (lvl) counts[lvl - 1]++
|
||||||
|
})
|
||||||
|
return (
|
||||||
|
<td key={m.id} className="p-2 border dark:border-gray-700 text-center bg-gray-50 dark:bg-gray-800/50">
|
||||||
|
<span className="inline-flex items-center gap-2 text-xs">
|
||||||
|
{counts.map((c, i) =>
|
||||||
|
c > 0 ? (
|
||||||
|
<span key={i} className="inline-flex items-center gap-0.5">
|
||||||
|
<span className={'inline-block w-2.5 h-2.5 rounded-full ' + dotColors[i]} />
|
||||||
|
<span className="font-semibold">{c}</span>
|
||||||
|
</span>
|
||||||
|
) : null
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
{!isCollapsed && g.catSkills.map((s) => (
|
||||||
|
<tr key={s.id} className="hover:bg-gray-50 dark:hover:bg-gray-800/50">
|
||||||
|
<td className="p-2 border dark:border-gray-700 font-medium sticky left-0 bg-white dark:bg-gray-950">
|
||||||
|
<span className="text-sm pl-6">{s.name}</span>
|
||||||
|
</td>
|
||||||
|
{members.map((m) => {
|
||||||
|
const key = `${m.id}-${s.id}`
|
||||||
|
const level = levels[key]
|
||||||
|
const canEditCell = isAdmin || currentUserId === m.id
|
||||||
|
const isEditing = editing === key
|
||||||
|
return (
|
||||||
|
<td key={m.id} className={`p-2 border dark:border-gray-700 text-center ${currentUserId === m.id ? 'bg-blue-50 dark:bg-blue-950/20' : ''}`}>
|
||||||
|
{isEditing && canEditCell ? (
|
||||||
|
<SkillLevelSelect
|
||||||
|
value={level?.level || 1}
|
||||||
|
onChange={(v) => onUpdate(m.id, s.id, v)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
level ? (
|
||||||
|
<SkillLevelBadge
|
||||||
|
level={level.level}
|
||||||
|
onClick={() => canEditCell && onEdit(key)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span
|
||||||
|
className="text-gray-300 dark:text-gray-600 text-sm cursor-pointer"
|
||||||
|
onClick={() => canEditCell && onEdit(key)}
|
||||||
|
>
|
||||||
|
—
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{isEditing && canEditCell && (
|
||||||
|
<button className="ml-1 text-xs text-red-500" onClick={onCancel}>✕</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { SkillLevelSelect } from '@/components/SkillLevelBadge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
|
import { Save } from 'lucide-react'
|
||||||
|
|
||||||
|
export function SkillMemberForm({ member, categories, skills, levels, isAdmin, currentUserId, onSave }) {
|
||||||
|
const canEdit = isAdmin || currentUserId === member.id
|
||||||
|
const initial = {}
|
||||||
|
skills.forEach((s) => {
|
||||||
|
const key = `${member.id}-${s.id}`
|
||||||
|
initial[s.id] = levels[key]?.level || 0
|
||||||
|
})
|
||||||
|
const [editedLevels, setEditedLevels] = useState(initial)
|
||||||
|
|
||||||
|
const hasChanges = skills.some((s) => {
|
||||||
|
const key = `${member.id}-${s.id}`
|
||||||
|
return (editedLevels[s.id] || 0) !== (levels[key]?.level || 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleSave() {
|
||||||
|
const changes = []
|
||||||
|
skills.forEach((s) => {
|
||||||
|
const key = `${member.id}-${s.id}`
|
||||||
|
const current = levels[key]?.level || 0
|
||||||
|
const edited = editedLevels[s.id] || 0
|
||||||
|
if (edited !== current) {
|
||||||
|
changes.push({ skillId: s.id, oldLevel: current || null, newLevel: edited })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (changes.length > 0) onSave(member.id, changes)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Modification des compétences de <span className="font-medium text-gray-700 dark:text-gray-200">{member.full_name || member.email}</span>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{categories.map((cat) => {
|
||||||
|
const catSkills = skills.filter((s) => s.category_id === cat.id)
|
||||||
|
if (catSkills.length === 0) return null
|
||||||
|
return (
|
||||||
|
<Card key={cat.id}>
|
||||||
|
<CardContent className="p-4 space-y-3">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider flex items-center gap-2">
|
||||||
|
<span className="w-2 h-2 rounded-full" style={{ backgroundColor: cat.color }} />
|
||||||
|
{cat.name}
|
||||||
|
</h3>
|
||||||
|
{catSkills.map((s) => (
|
||||||
|
<div key={s.id} className="flex items-center justify-between py-1.5 border-b dark:border-gray-800 last:border-0">
|
||||||
|
<span className="text-sm">{s.name}</span>
|
||||||
|
{canEdit ? (
|
||||||
|
<SkillLevelSelect
|
||||||
|
value={editedLevels[s.id] || 1}
|
||||||
|
onChange={(v) => setEditedLevels((prev) => ({ ...prev, [s.id]: v }))}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{levels[`${member.id}-${s.id}`]?.level || '—'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
|
||||||
|
{canEdit && (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button onClick={handleSave} disabled={!hasChanges}>
|
||||||
|
<Save className="h-4 w-4 mr-2" />
|
||||||
|
Enregistrer
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { Treemap, ResponsiveContainer, Tooltip } from 'recharts'
|
||||||
|
import { Card, CardContent } from '@/components/ui/card'
|
||||||
|
|
||||||
|
export default function TreemapView({
|
||||||
|
categories: allCategories, members, levels,
|
||||||
|
filterMember, filterCat,
|
||||||
|
filteredSkills, onCellClick,
|
||||||
|
}) {
|
||||||
|
const cats = allCategories
|
||||||
|
.filter((cat) => filterCat === 'all' || cat.id === filterCat)
|
||||||
|
.map((cat) => {
|
||||||
|
const catSkills = filteredSkills.filter((s) => s.category_id === cat.id)
|
||||||
|
return {
|
||||||
|
name: cat.name,
|
||||||
|
color: cat.color,
|
||||||
|
children: catSkills.map((s) => {
|
||||||
|
let total = 0
|
||||||
|
let count = 0
|
||||||
|
if (filterMember !== 'all') {
|
||||||
|
const key = `${filterMember}-${s.id}`
|
||||||
|
const lvl = levels[key]?.level
|
||||||
|
if (lvl) { total += lvl; count++ }
|
||||||
|
} else {
|
||||||
|
members.forEach((m) => {
|
||||||
|
const key = `${m.id}-${s.id}`
|
||||||
|
const lvl = levels[key]?.level
|
||||||
|
if (lvl) { total += lvl; count++ }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const avg = count > 0 ? +(total / count).toFixed(1) : 0
|
||||||
|
return {
|
||||||
|
name: s.name,
|
||||||
|
size: 1,
|
||||||
|
avg,
|
||||||
|
count,
|
||||||
|
skillId: s.id,
|
||||||
|
catId: cat.id,
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((cat) => cat.children.length > 0)
|
||||||
|
|
||||||
|
const flatData = cats
|
||||||
|
.flatMap((cat) => cat.children)
|
||||||
|
.sort((a, b) => b.avg - a.avg)
|
||||||
|
|
||||||
|
if (flatData.length === 0) {
|
||||||
|
return <div className="p-8 text-center text-gray-400">Aucune donnée à afficher</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
function getColor(avg) {
|
||||||
|
if (avg >= 3.5) return '#34d399'
|
||||||
|
if (avg >= 2.5) return '#fbbf24'
|
||||||
|
if (avg >= 1.5) return '#60a5fa'
|
||||||
|
return '#9ca3af'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="bg-white dark:bg-gray-950 rounded-lg p-4">
|
||||||
|
<ResponsiveContainer width="100%" height={500}>
|
||||||
|
<Treemap
|
||||||
|
data={flatData}
|
||||||
|
dataKey="size"
|
||||||
|
aspectRatio={4 / 3}
|
||||||
|
stroke="var(--background)"
|
||||||
|
fill="#8884d8"
|
||||||
|
content={({ x, y, width, height, payload }) => {
|
||||||
|
if (!payload) return null
|
||||||
|
return (
|
||||||
|
<g>
|
||||||
|
<rect
|
||||||
|
x={x}
|
||||||
|
y={y}
|
||||||
|
width={width}
|
||||||
|
height={height}
|
||||||
|
fill={getColor(payload.avg)}
|
||||||
|
stroke="var(--background)"
|
||||||
|
strokeWidth={2}
|
||||||
|
style={{ cursor: 'pointer' }}
|
||||||
|
onClick={() => onCellClick?.(payload.catId, filterMember !== 'all' ? filterMember : undefined)}
|
||||||
|
rx={4}
|
||||||
|
/>
|
||||||
|
{width > 40 && height > 30 && (
|
||||||
|
<>
|
||||||
|
<text
|
||||||
|
x={x + width / 2}
|
||||||
|
y={y + height / 2 - 4}
|
||||||
|
textAnchor="middle"
|
||||||
|
fill={payload.avg >= 2.5 ? '#000' : '#fff'}
|
||||||
|
fontSize={12}
|
||||||
|
fontWeight={600}
|
||||||
|
>
|
||||||
|
{payload.name}
|
||||||
|
</text>
|
||||||
|
<text
|
||||||
|
x={x + width / 2}
|
||||||
|
y={y + height / 2 + 12}
|
||||||
|
textAnchor="middle"
|
||||||
|
fill={payload.avg >= 2.5 ? '#000' : '#fff'}
|
||||||
|
fontSize={11}
|
||||||
|
>
|
||||||
|
{payload.avg}
|
||||||
|
{filterMember === 'all' && ` (${payload.count})`}
|
||||||
|
</text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
background: 'var(--background)',
|
||||||
|
border: '1px solid var(--border)',
|
||||||
|
borderRadius: 'var(--radius)',
|
||||||
|
fontSize: 13,
|
||||||
|
}}
|
||||||
|
formatter={(value, name, props) => {
|
||||||
|
const p = props.payload
|
||||||
|
if (!p) return []
|
||||||
|
return [
|
||||||
|
filterMember === 'all'
|
||||||
|
? `Moyenne : ${p.avg} — ${p.count} évalué(s)`
|
||||||
|
: `Niveau : ${p.avg}`,
|
||||||
|
p.name,
|
||||||
|
]
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Treemap>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{flatData.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardContent className="p-4">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-500 dark:text-gray-400 mb-3 uppercase tracking-wider">
|
||||||
|
Détail des compétences
|
||||||
|
</h3>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{flatData.map((s) => (
|
||||||
|
<div
|
||||||
|
key={s.skillId}
|
||||||
|
className="flex items-center justify-between py-1.5 px-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800 cursor-pointer text-sm"
|
||||||
|
onClick={() => onCellClick?.(s.catId, filterMember !== 'all' ? filterMember : undefined)}
|
||||||
|
>
|
||||||
|
<span>{s.name}</span>
|
||||||
|
<span className="font-medium">{s.avg}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Table2, Palette, ScatterChart, Share2, Grid3x3, Radar, BarChart3,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
const views = [
|
||||||
|
{ id: 'table', icon: Table2, label: 'Tableau' },
|
||||||
|
{ id: 'heat', icon: Palette, label: 'Matrice thermique' },
|
||||||
|
{ id: 'scatter', icon: ScatterChart, label: 'Nuage de points' },
|
||||||
|
{ id: 'graph', icon: Share2, label: 'Graphe' },
|
||||||
|
{ id: 'treemap', icon: Grid3x3, label: 'Treemap' },
|
||||||
|
{ id: 'radar', icon: Radar, label: 'Radar' },
|
||||||
|
{ id: 'bars', icon: BarChart3, label: 'Barres' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function ViewSwitcher({ active, onChange }) {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-1" role="group" aria-label="Mode d'affichage">
|
||||||
|
{views.map((v) => {
|
||||||
|
const Icon = v.icon
|
||||||
|
const isActive = active === v.id
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={v.id}
|
||||||
|
variant={isActive ? 'default' : 'outline'}
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={() => onChange(v.id)}
|
||||||
|
title={v.label}
|
||||||
|
>
|
||||||
|
<Icon className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||||
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
|
||||||
|
export function CategoryCard({ category, skills, onEdit, onDelete }) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between py-3">
|
||||||
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
|
<span className="w-3 h-3 rounded-full" style={{ backgroundColor: category.color }} />
|
||||||
|
{category.name}
|
||||||
|
<Badge variant="secondary" className="ml-2">{skills.length}</Badge>
|
||||||
|
</CardTitle>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => onEdit(category)}>
|
||||||
|
✎
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => onDelete(category.id)}>
|
||||||
|
🗑
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{skills.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-400 dark:text-gray-500">Aucune compétence dans cette catégorie</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{skills.map((s) => (
|
||||||
|
<Badge key={s.id} variant="outline" className="pr-1">
|
||||||
|
{s.name}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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 }
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import { supabase } from '@/lib/supabase'
|
||||||
|
|
||||||
|
export function useCategories() {
|
||||||
|
const [categories, setCategories] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const fetch = useCallback(async () => {
|
||||||
|
const { data, error } = await supabase.from('categories').select('*').order('name')
|
||||||
|
if (!error && data) setCategories(data)
|
||||||
|
setLoading(false)
|
||||||
|
return { data, error }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
useEffect(() => { fetch() }, [fetch])
|
||||||
|
|
||||||
|
return { categories, loading, refetch: fetch }
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { useEffect, useState, useCallback, useRef } from 'react'
|
||||||
|
import { supabase } from '@/lib/supabase'
|
||||||
|
|
||||||
|
const PAGE_SIZE = 50
|
||||||
|
|
||||||
|
export function useHistory() {
|
||||||
|
const [history, setHistory] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [count, setCount] = useState(0)
|
||||||
|
const [page, setPage] = useState(0)
|
||||||
|
const [filters, setFilters] = useState({ memberId: 'all', skillId: 'all' })
|
||||||
|
const channelRef = useRef(null)
|
||||||
|
|
||||||
|
const totalPages = Math.ceil(count / PAGE_SIZE)
|
||||||
|
|
||||||
|
const fetch = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
|
||||||
|
let query = supabase
|
||||||
|
.from('skill_history')
|
||||||
|
.select('*, member:member_id(full_name), skill:skill_id(name), changer:changed_by(full_name)', { count: 'exact' })
|
||||||
|
.order('created_at', { ascending: false })
|
||||||
|
.range(page * PAGE_SIZE, (page + 1) * PAGE_SIZE - 1)
|
||||||
|
|
||||||
|
if (filters.memberId !== 'all') query = query.eq('member_id', filters.memberId)
|
||||||
|
if (filters.skillId !== 'all') query = query.eq('skill_id', filters.skillId)
|
||||||
|
|
||||||
|
const { data, count: total } = await query
|
||||||
|
if (data) setHistory(data)
|
||||||
|
if (total !== null) setCount(total)
|
||||||
|
setLoading(false)
|
||||||
|
}, [page, filters])
|
||||||
|
|
||||||
|
/* eslint-disable react-hooks/set-state-in-effect */
|
||||||
|
useEffect(() => {
|
||||||
|
fetch()
|
||||||
|
|
||||||
|
channelRef.current = supabase
|
||||||
|
.channel('skill_history_changes')
|
||||||
|
.on('postgres_changes', { event: 'INSERT', schema: 'public', table: 'skill_history' }, () => {
|
||||||
|
if (page === 0) fetch()
|
||||||
|
})
|
||||||
|
.subscribe()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
channelRef.current?.unsubscribe()
|
||||||
|
}
|
||||||
|
}, [fetch, page])
|
||||||
|
/* eslint-enable react-hooks/set-state-in-effect */
|
||||||
|
|
||||||
|
function setFilter(key, value) {
|
||||||
|
setFilters((f) => ({ ...f, [key]: value }))
|
||||||
|
setPage(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
function nextPage() {
|
||||||
|
if (page < totalPages - 1) setPage((p) => p + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
function prevPage() {
|
||||||
|
if (page > 0) setPage((p) => p - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
history, loading, count, page, totalPages, filters,
|
||||||
|
setFilter, nextPage, prevPage, refetch: fetch,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
|
import { supabase } from '@/lib/supabase'
|
||||||
|
|
||||||
|
export function useMembers() {
|
||||||
|
const [members, setMembers] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
const fetch = useCallback(async () => {
|
||||||
|
const { data, error } = await supabase.from('members').select('*').order('full_name')
|
||||||
|
if (!error && data) setMembers(data)
|
||||||
|
setLoading(false)
|
||||||
|
return { data, error }
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
useEffect(() => { fetch() }, [fetch])
|
||||||
|
|
||||||
|
return { members, loading, refetch: fetch }
|
||||||
|
}
|
||||||
@@ -0,0 +1,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 }
|
||||||
|
}
|
||||||
@@ -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
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx } from "clsx";
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs) {
|
||||||
|
return twMerge(clsx(inputs));
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
)
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Supabase
|
||||||
|
.branches
|
||||||
|
.temp
|
||||||
|
|
||||||
|
# dotenvx
|
||||||
|
.env.keys
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
@@ -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\"}"
|
||||||
@@ -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));
|
||||||
@@ -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
|
||||||
|
@@ -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
|
||||||
|
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"rewrites": [{ "source": "/(.*)", "destination": "/index.html" }]
|
||||||
|
}
|
||||||
@@ -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'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user