Remaster avec skill supabase only et grillme
This commit is contained in:
+5
-1
@@ -4,5 +4,9 @@
|
||||
# Supabase API URL
|
||||
VITE_SUPABASE_URL=http://localhost:8000
|
||||
|
||||
# Supabase anonymous key (public)
|
||||
# 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
|
||||
|
||||
@@ -11,27 +11,51 @@ 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 test runner, no typecheck script.
|
||||
No typecheck script.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Entry**: `src/main.jsx` → `src/App.jsx`
|
||||
- **Entry**: `src/main.jsx` → wrapped in `<ThemeProvider>` (next-themes) → `src/App.jsx`
|
||||
- **Auth**: `src/context/AuthContext.jsx` — Supabase Auth + `members` table profile (role: admin/member)
|
||||
- **Routing**: `src/App.jsx` — public routes `/login`, `/register`, `/accept-invite`; protected routes wrapped in `<ProtectedRoute>`; admin-only routes (`/members`, `/skills`) also wrapped in `<AdminRoute>`
|
||||
- **Routing**: `src/App.jsx` — public routes `/login`, `/register`, `/accept-invite`; protected routes wrapped in `<ProtectedRoute>`; admin-only routes (`/members`, `/skills`) also wrapped in `<AdminRoute>` + `<Suspense>` (React.lazy code splitting)
|
||||
- **Error handling**: `<ErrorBoundary>` wraps all protected routes
|
||||
- **Pages**: `src/pages/` — Dashboard, Members, Skills, SkillMatrix, History, Profile
|
||||
- **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
|
||||
- `.env` points to a local Supabase instance (`192.168.2.220:8000`) — this is committed but `.env` is gitignored; adjust for your environment
|
||||
- UPDATE policies include `WITH CHECK` matching the `USING` clause
|
||||
- `invitations_read_admin` policy filters `expires_at > now()`
|
||||
- Full-text search enabled via GIN indexes on `skills.name` and `members.full_name`
|
||||
- Realtime publication enabled on `skill_levels` and `skill_history`
|
||||
- `.env` points to a local Supabase instance (`vm-docker5.home.arpa:8000`) — this is committed but `.env` is gitignored; adjust for your environment
|
||||
|
||||
## Docker
|
||||
|
||||
@@ -53,3 +77,6 @@ Components land in `src/components/ui/`. Uses Lucide icons.
|
||||
- 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
|
||||
|
||||
@@ -18,4 +18,15 @@ export default defineConfig([
|
||||
parserOptions: { ecmaFeatures: { jsx: true } },
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['src/components/ui/**'],
|
||||
rules: {
|
||||
'no-unused-vars': 'off',
|
||||
'react-refresh/only-export-components': 'off',
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ['vite.config.js'],
|
||||
rules: { 'no-undef': 'off' },
|
||||
},
|
||||
])
|
||||
|
||||
Generated
+362
-3
@@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "gestiondescmpetences",
|
||||
"name": "gestiondescompetences",
|
||||
"version": "0.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "gestiondescmpetences",
|
||||
"name": "gestiondescompetences",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@fontsource-variable/geist": "^5.2.9",
|
||||
@@ -36,7 +36,8 @@
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.6.0",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"vite": "^8.0.12"
|
||||
"vite": "^8.0.12",
|
||||
"vitest": "^4.1.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
@@ -3017,6 +3018,13 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@supabase/auth-js": {
|
||||
"version": "2.105.4",
|
||||
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.105.4.tgz",
|
||||
@@ -3395,6 +3403,24 @@
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/chai": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/deep-eql": "*",
|
||||
"assertion-error": "^2.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/deep-eql": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/esrecurse": {
|
||||
"version": "4.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
|
||||
@@ -3492,6 +3518,119 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.7.tgz",
|
||||
"integrity": "sha512-1R+tw0ortHEbZDGMymm+pN7/AFQ/RkFFdtd7EN+VBpynKmLbP8A3rpEXdshBJ7+8hQ9zBJh/i1s0yKNtxAnU7w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "4.1.7",
|
||||
"@vitest/utils": "4.1.7",
|
||||
"chai": "^6.2.2",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.7.tgz",
|
||||
"integrity": "sha512-vY7nuamKgfvpA1Koa3oYIw/k7D6kZnpGyNMZW8loow2bsBYla1TFdqTaXncWdRn4pgwNs+90RhnXhJScDwQeJA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "4.1.7",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.21"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"msw": "^2.4.9",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"msw": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz",
|
||||
"integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.7.tgz",
|
||||
"integrity": "sha512-BapjmAQ2aI78WdMEfeUWivnfVzB+VPGwWRQcJE0OUq7qEeEcBsCSf+0T5iREBNE5nBb4wA5Ya0W6IA+sghdEFw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "4.1.7",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.7.tgz",
|
||||
"integrity": "sha512-ZacLzja+TmJeZ1h14xW2FB/WpeimUD3haBXQPyJqxvo8jQTmfeA8zv58mtjN2C7EHXZDYVcVYdYmAxjkWVvKCw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.7",
|
||||
"@vitest/utils": "4.1.7",
|
||||
"magic-string": "^0.30.21",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.7.tgz",
|
||||
"integrity": "sha512-kbkI5LMWakyuTIvs6fUJ5qdIVb1XVKsYJAT4OJ938cHMROYMSfmoQdZy0aaAnjbbc8F61vkoTqz/Az+/HiIu5Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz",
|
||||
"integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.1.7",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/accepts": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
|
||||
@@ -3638,6 +3777,16 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/assertion-error": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
|
||||
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/ast-types": {
|
||||
"version": "0.16.1",
|
||||
"resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz",
|
||||
@@ -3834,6 +3983,16 @@
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/chai": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/chalk": {
|
||||
"version": "5.6.2",
|
||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
|
||||
@@ -4367,6 +4526,13 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/es-module-lexer": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz",
|
||||
"integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/es-object-atoms": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||
@@ -4592,6 +4758,16 @@
|
||||
"node": ">=4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/estree-walker": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
|
||||
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/estree": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/esutils": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
|
||||
@@ -4658,6 +4834,16 @@
|
||||
"url": "https://github.com/sindresorhus/execa?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/expect-type": {
|
||||
"version": "1.3.0",
|
||||
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz",
|
||||
"integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/express": {
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
|
||||
@@ -6405,6 +6591,17 @@
|
||||
"node": ">= 10"
|
||||
}
|
||||
},
|
||||
"node_modules/obug": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz",
|
||||
"integrity": "sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
"https://github.com/sponsors/sxzz",
|
||||
"https://opencollective.com/debug"
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/on-finished": {
|
||||
"version": "2.4.1",
|
||||
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
|
||||
@@ -6622,6 +6819,13 @@
|
||||
"integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pathe": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz",
|
||||
"integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/pg": {
|
||||
"version": "8.21.0",
|
||||
"resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz",
|
||||
@@ -7559,6 +7763,13 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/siginfo": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
|
||||
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/signal-exit": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
|
||||
@@ -7614,6 +7825,13 @@
|
||||
"node": ">= 10.x"
|
||||
}
|
||||
},
|
||||
"node_modules/stackback": {
|
||||
"version": "0.0.2",
|
||||
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
|
||||
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/statuses": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||
@@ -7623,6 +7841,13 @@
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/std-env": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz",
|
||||
"integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/stdin-discarder": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz",
|
||||
@@ -7760,6 +7985,23 @@
|
||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinybench": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz",
|
||||
"integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyexec": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.2.tgz",
|
||||
"integrity": "sha512-M/Q0B2cp4K7kynaT/vnED1j8TlLY+Pp7C6Wl2bl/7u/F0mUVwdyOpwomQb8JpYLitHUssAJRmLZdMCGsrx7i+g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.16",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
|
||||
@@ -7777,6 +8019,16 @@
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyrainbow": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz",
|
||||
"integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tldts": {
|
||||
"version": "7.0.30",
|
||||
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.30.tgz",
|
||||
@@ -8165,6 +8417,96 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "4.1.7",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.7.tgz",
|
||||
"integrity": "sha512-flYyaFd2CgoCoU+0UKt3pxksgC+S02iTDN0n3LtqaMeXsI9SBcdNujc2k0DeFLzUn/0k538yNjOSdwgCqcrwJA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.1.7",
|
||||
"@vitest/mocker": "4.1.7",
|
||||
"@vitest/pretty-format": "4.1.7",
|
||||
"@vitest/runner": "4.1.7",
|
||||
"@vitest/snapshot": "4.1.7",
|
||||
"@vitest/spy": "4.1.7",
|
||||
"@vitest/utils": "4.1.7",
|
||||
"es-module-lexer": "^2.0.0",
|
||||
"expect-type": "^1.3.0",
|
||||
"magic-string": "^0.30.21",
|
||||
"obug": "^2.1.1",
|
||||
"pathe": "^2.0.3",
|
||||
"picomatch": "^4.0.3",
|
||||
"std-env": "^4.0.0-rc.1",
|
||||
"tinybench": "^2.9.0",
|
||||
"tinyexec": "^1.0.2",
|
||||
"tinyglobby": "^0.2.15",
|
||||
"tinyrainbow": "^3.1.0",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
|
||||
"why-is-node-running": "^2.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"vitest": "vitest.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.0.0 || ^22.0.0 || >=24.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@edge-runtime/vm": "*",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||
"@vitest/browser-playwright": "4.1.7",
|
||||
"@vitest/browser-preview": "4.1.7",
|
||||
"@vitest/browser-webdriverio": "4.1.7",
|
||||
"@vitest/coverage-istanbul": "4.1.7",
|
||||
"@vitest/coverage-v8": "4.1.7",
|
||||
"@vitest/ui": "4.1.7",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edge-runtime/vm": {
|
||||
"optional": true
|
||||
},
|
||||
"@opentelemetry/api": {
|
||||
"optional": true
|
||||
},
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-playwright": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-preview": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/browser-webdriverio": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/coverage-istanbul": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/coverage-v8": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/ui": {
|
||||
"optional": true
|
||||
},
|
||||
"happy-dom": {
|
||||
"optional": true
|
||||
},
|
||||
"jsdom": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/web-streams-polyfill": {
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
|
||||
@@ -8189,6 +8531,23 @@
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/why-is-node-running": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
|
||||
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"siginfo": "^2.0.0",
|
||||
"stackback": "0.0.2"
|
||||
},
|
||||
"bin": {
|
||||
"why-is-node-running": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/word-wrap": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||
|
||||
+7
-4
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "gestiondescmpetences",
|
||||
"name": "gestiondescompetences",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
@@ -7,7 +7,9 @@
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource-variable/geist": "^5.2.9",
|
||||
@@ -16,7 +18,6 @@
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.16.0",
|
||||
"next-themes": "^0.4.6",
|
||||
"pg": "^8.21.0",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.6",
|
||||
"react-dom": "^19.2.6",
|
||||
@@ -38,6 +39,8 @@
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.6.0",
|
||||
"tailwindcss": "^4.3.0",
|
||||
"vite": "^8.0.12"
|
||||
"vite": "^8.0.12",
|
||||
"pg": "^8.21.0",
|
||||
"vitest": "^4.1.7"
|
||||
}
|
||||
}
|
||||
|
||||
+19
-6
@@ -1,11 +1,24 @@
|
||||
import { readFileSync } from 'fs'
|
||||
import { resolve, dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
|
||||
const SUPABASE_URL = 'http://192.168.2.220:8000'
|
||||
const ANON_KEY = readFileSync('/home/tophe/10-Projets/DevOps/OpenCode/GestionDesCompetences/.env', 'utf8')
|
||||
.split('\n')
|
||||
.find(l => l.startsWith('VITE_SUPABASE_ANON_KEY='))
|
||||
?.split('=').slice(1).join('=')
|
||||
const SERVICE_ROLE_KEY = 'eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImJmMzI4YWViLTYwMWYtNGEzZC04MjdiLTY1MTZlZTY0MWViMyJ9.eyJyb2xlIjoic2VydmljZV9yb2xlIiwiaXNzIjoic3VwYWJhc2UiLCJpYXQiOjE3NzkzMDAyNDMsImV4cCI6MTkzNjk4MDI0M30.qt2IKVgwaQQkHIZVWH4tEcrozU0mT3F9dNC9Yo83UidKwsoxHRqZz8hBWjreRPsThUcCgjxOmhwxeTB7Zd7RFA'
|
||||
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,
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
{
|
||||
"version": 1,
|
||||
"skills": {
|
||||
"grill-me": {
|
||||
"source": "mattpocock/skills",
|
||||
"sourceType": "github",
|
||||
"skillPath": "skills/productivity/grill-me/SKILL.md",
|
||||
"computedHash": "784f0dbb7403b0f00324bce9a112f715342777a0daee7bbb7385f9c6f0a170ea"
|
||||
},
|
||||
"pptx": {
|
||||
"source": "anthropics/skills",
|
||||
"sourceType": "github",
|
||||
|
||||
+42
-27
@@ -1,21 +1,30 @@
|
||||
import { lazy, Suspense } from 'react'
|
||||
import { BrowserRouter, Routes, Route } from 'react-router-dom'
|
||||
import { AuthProvider } from '@/context/AuthContext'
|
||||
import { ProtectedRoute, AdminRoute } from '@/components/ProtectedRoute'
|
||||
import { ErrorBoundary } from '@/components/ErrorBoundary'
|
||||
import { Layout } from '@/components/Layout'
|
||||
import { Toaster } from 'sonner'
|
||||
import { Login } from '@/pages/Login'
|
||||
import { Register } from '@/pages/Register'
|
||||
import { AcceptInvite } from '@/pages/AcceptInvite'
|
||||
import { Dashboard } from '@/pages/Dashboard'
|
||||
import { Members } from '@/pages/Members'
|
||||
import { Skills } from '@/pages/Skills'
|
||||
import { SkillMatrix } from '@/pages/SkillMatrix'
|
||||
import { History } from '@/pages/History'
|
||||
import { Profile } from '@/pages/Profile'
|
||||
|
||||
const Members = lazy(() => import('@/pages/Members').then(m => ({ default: m.Members })))
|
||||
const Skills = lazy(() => import('@/pages/Skills').then(m => ({ default: m.Skills })))
|
||||
|
||||
function AppLayout({ children }) {
|
||||
return <Layout>{children}</Layout>
|
||||
}
|
||||
|
||||
function SuspenseWrapper({ children }) {
|
||||
return (
|
||||
<Layout>{children}</Layout>
|
||||
<Suspense fallback={<div className="flex items-center justify-center min-h-[60vh] text-gray-400">Chargement...</div>}>
|
||||
{children}
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -23,32 +32,38 @@ export default function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<AuthProvider>
|
||||
<Toaster />
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/accept-invite" element={<AcceptInvite />} />
|
||||
<ErrorBoundary>
|
||||
<Toaster />
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/accept-invite" element={<AcceptInvite />} />
|
||||
|
||||
<Route path="/" element={
|
||||
<ProtectedRoute><AppLayout><Dashboard /></AppLayout></ProtectedRoute>
|
||||
} />
|
||||
<Route path="/matrix" element={
|
||||
<ProtectedRoute><AppLayout><SkillMatrix /></AppLayout></ProtectedRoute>
|
||||
} />
|
||||
<Route path="/history" element={
|
||||
<ProtectedRoute><AppLayout><History /></AppLayout></ProtectedRoute>
|
||||
} />
|
||||
<Route path="/profile" element={
|
||||
<ProtectedRoute><AppLayout><Profile /></AppLayout></ProtectedRoute>
|
||||
} />
|
||||
<Route path="/" element={
|
||||
<ProtectedRoute><AppLayout><Dashboard /></AppLayout></ProtectedRoute>
|
||||
} />
|
||||
<Route path="/matrix" element={
|
||||
<ProtectedRoute><AppLayout><SkillMatrix /></AppLayout></ProtectedRoute>
|
||||
} />
|
||||
<Route path="/history" element={
|
||||
<ProtectedRoute><AppLayout><History /></AppLayout></ProtectedRoute>
|
||||
} />
|
||||
<Route path="/profile" element={
|
||||
<ProtectedRoute><AppLayout><Profile /></AppLayout></ProtectedRoute>
|
||||
} />
|
||||
|
||||
<Route path="/members" element={
|
||||
<ProtectedRoute><AdminRoute><AppLayout><Members /></AppLayout></AdminRoute></ProtectedRoute>
|
||||
} />
|
||||
<Route path="/skills" element={
|
||||
<ProtectedRoute><AdminRoute><AppLayout><Skills /></AppLayout></AdminRoute></ProtectedRoute>
|
||||
} />
|
||||
</Routes>
|
||||
<Route path="/members" element={
|
||||
<ProtectedRoute><AdminRoute><AppLayout>
|
||||
<SuspenseWrapper><Members /></SuspenseWrapper>
|
||||
</AppLayout></AdminRoute></ProtectedRoute>
|
||||
} />
|
||||
<Route path="/skills" element={
|
||||
<ProtectedRoute><AdminRoute><AppLayout>
|
||||
<SuspenseWrapper><Skills /></SuspenseWrapper>
|
||||
</AppLayout></AdminRoute></ProtectedRoute>
|
||||
} />
|
||||
</Routes>
|
||||
</ErrorBoundary>
|
||||
</AuthProvider>
|
||||
</BrowserRouter>
|
||||
)
|
||||
|
||||
+83
-40
@@ -1,3 +1,4 @@
|
||||
import { useState } from 'react'
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '@/context/AuthContext'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -5,75 +6,117 @@ import { Avatar, AvatarFallback } from '@/components/ui/avatar'
|
||||
import {
|
||||
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
LayoutDashboard, BookOpen, Users, Table2, History, Menu, X, Sun, Moon, LogOut, User,
|
||||
} from 'lucide-react'
|
||||
import { useTheme } from 'next-themes'
|
||||
|
||||
const navItems = [
|
||||
{ to: '/', label: 'Tableau de bord', icon: '📊' },
|
||||
{ to: '/skills', label: 'Compétences', icon: '📚', admin: true },
|
||||
{ to: '/members', label: 'Membres', icon: '👥', admin: true },
|
||||
{ to: '/matrix', label: 'Matrice', icon: '📋' },
|
||||
{ to: '/history', label: 'Historique', icon: '📜' },
|
||||
{ to: '/', label: 'Tableau de bord', icon: LayoutDashboard },
|
||||
{ to: '/skills', label: 'Compétences', icon: BookOpen, admin: true },
|
||||
{ to: '/members', label: 'Membres', icon: Users, admin: true },
|
||||
{ to: '/matrix', label: 'Matrice', icon: Table2 },
|
||||
{ to: '/history', label: 'Historique', icon: History },
|
||||
]
|
||||
|
||||
export function Layout({ children }) {
|
||||
const { profile, signOut } = useAuth()
|
||||
const { theme, setTheme } = useTheme()
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||
|
||||
async function handleSignOut() {
|
||||
await signOut()
|
||||
navigate('/login')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex">
|
||||
<aside className="w-64 bg-gray-900 text-white flex flex-col">
|
||||
<div className="p-4 border-b border-gray-700">
|
||||
const sidebar = (
|
||||
<aside className={`w-64 bg-gray-900 text-white flex flex-col shrink-0 ${sidebarOpen ? 'fixed inset-0 z-50' : 'hidden lg:flex'}`}>
|
||||
<div className="p-4 border-b border-gray-700 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-lg font-bold">Compétences</h1>
|
||||
<p className="text-xs text-gray-400">Équipe SysAdmin</p>
|
||||
</div>
|
||||
<nav className="flex-1 p-2 space-y-1">
|
||||
{navItems
|
||||
.filter((item) => !item.admin || profile?.role === 'admin')
|
||||
.map((item) => (
|
||||
{sidebarOpen && (
|
||||
<button className="lg:hidden text-gray-400 hover:text-white" onClick={() => setSidebarOpen(false)}>
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<nav className="flex-1 p-2 space-y-1">
|
||||
{navItems
|
||||
.filter((item) => !item.admin || profile?.role === 'admin')
|
||||
.map((item) => {
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<Link
|
||||
key={item.to}
|
||||
to={item.to}
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className={`flex items-center gap-3 px-3 py-2 rounded-md text-sm transition-colors ${
|
||||
location.pathname === item.to
|
||||
? 'bg-gray-700 text-white'
|
||||
: 'text-gray-300 hover:bg-gray-800 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<span>{item.icon}</span>
|
||||
<Icon className="h-4 w-4" />
|
||||
{item.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
<div className="p-4 border-t border-gray-700">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="w-full flex items-center gap-2 text-gray-300 hover:text-white">
|
||||
<Avatar className="h-6 w-6">
|
||||
<AvatarFallback className="text-xs">
|
||||
{profile?.full_name?.charAt(0)?.toUpperCase() || '?'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-sm truncate">{profile?.full_name || profile?.email}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem onClick={() => navigate('/profile')}>
|
||||
Mon profil
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleSignOut}>
|
||||
Déconnexion
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
<div className="p-4 border-t border-gray-700 space-y-2">
|
||||
<button
|
||||
className="flex items-center gap-2 text-sm text-gray-400 hover:text-white w-full"
|
||||
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
|
||||
>
|
||||
{theme === 'dark' ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
|
||||
{theme === 'dark' ? 'Mode clair' : 'Mode sombre'}
|
||||
</button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" className="w-full flex items-center gap-2 text-gray-300 hover:text-white">
|
||||
<Avatar className="h-6 w-6">
|
||||
<AvatarFallback className="text-xs">
|
||||
{profile?.full_name?.charAt(0)?.toUpperCase() || '?'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="text-sm truncate">{profile?.full_name || profile?.email}</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48">
|
||||
<DropdownMenuItem onClick={() => navigate('/profile')}>
|
||||
<User className="h-4 w-4 mr-2" />
|
||||
Mon profil
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={handleSignOut}>
|
||||
<LogOut className="h-4 w-4 mr-2" />
|
||||
Déconnexion
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex bg-gray-50 dark:bg-gray-950 dark:text-gray-100">
|
||||
{sidebarOpen && (
|
||||
<div className="fixed inset-0 bg-black/50 z-40 lg:hidden" onClick={() => setSidebarOpen(false)} />
|
||||
)}
|
||||
{sidebar}
|
||||
<main className="flex-1 flex flex-col min-w-0">
|
||||
<div className="lg:hidden flex items-center justify-between p-4 border-b bg-white dark:bg-gray-900 dark:border-gray-800">
|
||||
<button onClick={() => setSidebarOpen(true)} className="text-gray-700 dark:text-gray-300">
|
||||
<Menu className="h-5 w-5" />
|
||||
</button>
|
||||
<span className="font-bold text-sm">Compétences</span>
|
||||
<div className="w-5" />
|
||||
</div>
|
||||
<div className="p-4 md:p-8 overflow-auto">
|
||||
{children}
|
||||
</div>
|
||||
</aside>
|
||||
<main className="flex-1 bg-gray-50 p-8 overflow-auto">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
|
||||
const levelConfig = {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable react-refresh/only-export-components */
|
||||
import { createContext, useContext, useEffect, useState } from 'react'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
|
||||
|
||||
+4
-1
@@ -1,10 +1,13 @@
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { ThemeProvider } from 'next-themes'
|
||||
import './index.css'
|
||||
import App from './App.jsx'
|
||||
|
||||
createRoot(document.getElementById('root')).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
<ThemeProvider attribute="class" defaultTheme="system" enableSystem>
|
||||
<App />
|
||||
</ThemeProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
|
||||
@@ -16,29 +16,36 @@ export function AcceptInvite() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [valid, setValid] = useState(false)
|
||||
|
||||
/* eslint-disable react-hooks/set-state-in-effect */
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
async function checkToken() {
|
||||
const { data, error } = await supabase
|
||||
const { data } = await supabase
|
||||
.from('invitations')
|
||||
.select('*')
|
||||
.eq('token', token)
|
||||
.eq('accepted', false)
|
||||
.gte('expires_at', new Date().toISOString())
|
||||
.single()
|
||||
if (data && !error) {
|
||||
setEmail(data.email)
|
||||
setValid(true)
|
||||
if (!cancelled) {
|
||||
if (data) {
|
||||
setEmail(data.email)
|
||||
setValid(true)
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
if (token) checkToken()
|
||||
else setLoading(false)
|
||||
return () => { cancelled = true }
|
||||
}, [token])
|
||||
/* eslint-enable react-hooks/set-state-in-effect */
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
|
||||
const { data, error } = await supabase.auth.signUp({
|
||||
const { error } = await supabase.auth.signUp({
|
||||
email,
|
||||
password,
|
||||
options: { data: { full_name: name } },
|
||||
@@ -50,7 +57,6 @@ export function AcceptInvite() {
|
||||
return
|
||||
}
|
||||
|
||||
// Marquer l'invitation comme acceptée
|
||||
await supabase.from('invitations').update({ accepted: true }).eq('token', token)
|
||||
|
||||
toast.success('Compte créé ! Vous pouvez vous connecter.')
|
||||
@@ -69,11 +75,11 @@ export function AcceptInvite() {
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100">
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-950">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-2xl text-center">Accepter l'invitation</CardTitle>
|
||||
<p className="text-sm text-gray-500 text-center mt-1">{email}</p>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 text-center mt-1">{email}</p>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
|
||||
@@ -29,7 +29,6 @@ export function Dashboard() {
|
||||
})
|
||||
setRecentChanges(history.data || [])
|
||||
|
||||
// Compétences les mieux notées (moyenne)
|
||||
const { data: levels } = await supabase
|
||||
.from('skill_levels')
|
||||
.select('skill_id, level, skill:skill_id(name)')
|
||||
@@ -54,7 +53,7 @@ export function Dashboard() {
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold">Tableau de bord</h1>
|
||||
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-lg">Membres</CardTitle></CardHeader>
|
||||
<CardContent><p className="text-3xl font-bold">{stats.members}</p></CardContent>
|
||||
@@ -69,12 +68,12 @@ export function Dashboard() {
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<Card>
|
||||
<CardHeader><CardTitle className="text-lg">Compétences les mieux notées</CardTitle></CardHeader>
|
||||
<CardContent>
|
||||
{topSkills.length === 0 ? (
|
||||
<p className="text-gray-500">Aucune évaluation pour le moment</p>
|
||||
<p className="text-gray-500 dark:text-gray-400">Aucune évaluation pour le moment</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{topSkills.map((s) => (
|
||||
@@ -92,11 +91,11 @@ export function Dashboard() {
|
||||
<CardHeader><CardTitle className="text-lg">Dernières évolutions</CardTitle></CardHeader>
|
||||
<CardContent className="max-h-80 overflow-auto">
|
||||
{recentChanges.length === 0 ? (
|
||||
<p className="text-gray-500">Aucun changement récent</p>
|
||||
<p className="text-gray-500 dark:text-gray-400">Aucun changement récent</p>
|
||||
) : (
|
||||
<ul className="space-y-3">
|
||||
{recentChanges.map((c) => (
|
||||
<li key={c.id} className="text-sm border-b pb-2 last:border-0">
|
||||
<li key={c.id} className="text-sm border-b dark:border-gray-800 pb-2 last:border-0">
|
||||
<span className="font-medium">{c.member?.full_name}</span>
|
||||
{' '}a mis à jour{' '}
|
||||
<span className="font-medium">{c.skill?.name}</span>
|
||||
|
||||
+34
-47
@@ -1,44 +1,15 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { useMembers } from '@/hooks/useMembers'
|
||||
import { useSkills } from '@/hooks/useSkills'
|
||||
import { useHistory } from '@/hooks/useHistory'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { SkillLevelBadge } from '@/components/SkillLevelBadge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
|
||||
export function History() {
|
||||
const [history, setHistory] = useState([])
|
||||
const [members, setMembers] = useState([])
|
||||
const [skills, setSkills] = useState([])
|
||||
const [filterMember, setFilterMember] = useState('all')
|
||||
const [filterSkill, setFilterSkill] = useState('all')
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
const [memRes, skillRes] = await Promise.all([
|
||||
supabase.from('members').select('*').order('full_name'),
|
||||
supabase.from('skills').select('*').order('name'),
|
||||
])
|
||||
if (memRes.data) setMembers(memRes.data)
|
||||
if (skillRes.data) setSkills(skillRes.data)
|
||||
}
|
||||
load()
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
async function loadHistory() {
|
||||
let query = supabase
|
||||
.from('skill_history')
|
||||
.select('*, member:member_id(full_name), skill:skill_id(name), changer:changed_by(full_name)')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(100)
|
||||
|
||||
if (filterMember !== 'all') query = query.eq('member_id', filterMember)
|
||||
if (filterSkill !== 'all') query = query.eq('skill_id', filterSkill)
|
||||
|
||||
const { data } = await query
|
||||
if (data) setHistory(data)
|
||||
}
|
||||
loadHistory()
|
||||
}, [filterMember, filterSkill])
|
||||
const { members } = useMembers()
|
||||
const { skills } = useSkills()
|
||||
const { history, loading, count, page, totalPages, filters, setFilter, nextPage, prevPage } = useHistory()
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -46,9 +17,9 @@ export function History() {
|
||||
|
||||
<div className="flex gap-4">
|
||||
<select
|
||||
className="border rounded px-2 py-1 text-sm"
|
||||
value={filterMember}
|
||||
onChange={(e) => setFilterMember(e.target.value)}
|
||||
className="border rounded px-2 py-1 text-sm dark:bg-gray-800 dark:border-gray-700"
|
||||
value={filters.memberId}
|
||||
onChange={(e) => setFilter('memberId', e.target.value)}
|
||||
>
|
||||
<option value="all">Tous les membres</option>
|
||||
{members.map((m) => (
|
||||
@@ -56,9 +27,9 @@ export function History() {
|
||||
))}
|
||||
</select>
|
||||
<select
|
||||
className="border rounded px-2 py-1 text-sm"
|
||||
value={filterSkill}
|
||||
onChange={(e) => setFilterSkill(e.target.value)}
|
||||
className="border rounded px-2 py-1 text-sm dark:bg-gray-800 dark:border-gray-700"
|
||||
value={filters.skillId}
|
||||
onChange={(e) => setFilter('skillId', e.target.value)}
|
||||
>
|
||||
<option value="all">Toutes les compétences</option>
|
||||
{skills.map((s) => (
|
||||
@@ -69,10 +40,12 @@ export function History() {
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{history.length === 0 ? (
|
||||
{loading ? (
|
||||
<p className="p-6 text-gray-400">Chargement...</p>
|
||||
) : history.length === 0 ? (
|
||||
<p className="p-6 text-gray-400">Aucun historique</p>
|
||||
) : (
|
||||
<ul className="divide-y">
|
||||
<ul className="divide-y dark:divide-gray-800">
|
||||
{history.map((h) => (
|
||||
<li key={h.id} className="p-4 flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
@@ -80,7 +53,7 @@ export function History() {
|
||||
<span className="font-medium">{h.member?.full_name}</span>
|
||||
{' → '}<span className="font-medium">{h.skill?.name}</span>
|
||||
</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
Par {h.changer?.full_name} — {new Date(h.created_at).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
@@ -95,6 +68,20 @@ export function History() {
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between text-sm text-gray-500 dark:text-gray-400">
|
||||
<span>{count} entrées — Page {page + 1} / {totalPages}</span>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" size="sm" onClick={prevPage} disabled={page === 0}>
|
||||
<ChevronLeft className="h-4 w-4" /> Précédent
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={nextPage} disabled={page >= totalPages - 1}>
|
||||
Suivant <ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
+24
-2
@@ -4,10 +4,11 @@ import { useAuth } from '@/context/AuthContext'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||
import { InviteUserModal } from '@/components/InviteUserModal'
|
||||
import { Download } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export function Members() {
|
||||
@@ -37,11 +38,32 @@ export function Members() {
|
||||
toast.success('Membre supprimé')
|
||||
}
|
||||
|
||||
function exportCSV() {
|
||||
const header = 'Nom,Email,Rôle,Inscrit le\n'
|
||||
const rows = members.map((m) =>
|
||||
`"${m.full_name || ''}","${m.email}","${m.role}","${new Date(m.created_at).toLocaleDateString()}"`
|
||||
).join('\n')
|
||||
const blob = new Blob([header + rows], { type: 'text/csv;charset=utf-8;' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'membres.csv'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
toast.success('Fichier CSV téléchargé')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold">Membres</h1>
|
||||
<InviteUserModal />
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={exportCSV}>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
CSV
|
||||
</Button>
|
||||
<InviteUserModal />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
|
||||
@@ -27,15 +27,15 @@ export function Profile() {
|
||||
<CardHeader><CardTitle>Informations</CardTitle></CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm text-gray-600">Email</label>
|
||||
<label className="text-sm text-gray-600 dark:text-gray-400">Email</label>
|
||||
<p className="font-medium">{profile?.email}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-gray-600">Rôle</label>
|
||||
<label className="text-sm text-gray-600 dark:text-gray-400">Rôle</label>
|
||||
<p className="font-medium capitalize">{profile?.role}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-gray-600">Nom complet</label>
|
||||
<label className="text-sm text-gray-600 dark:text-gray-400">Nom complet</label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} />
|
||||
</div>
|
||||
<Button onClick={handleSave}>Enregistrer</Button>
|
||||
|
||||
+45
-159
@@ -1,42 +1,30 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import { useState } from 'react'
|
||||
import { useAuth } from '@/context/AuthContext'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { SkillLevelBadge, SkillLevelSelect } from '@/components/SkillLevelBadge'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { useCategories } from '@/hooks/useCategories'
|
||||
import { useSkills } from '@/hooks/useSkills'
|
||||
import { useMembers } from '@/hooks/useMembers'
|
||||
import { useSkillLevels } from '@/hooks/useSkillLevels'
|
||||
import { SkillMatrixFilters } from '@/components/matrix/SkillMatrixFilters'
|
||||
import { SkillMatrixTable } from '@/components/matrix/SkillMatrixTable'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export function SkillMatrix() {
|
||||
const { profile } = useAuth()
|
||||
const isAdmin = profile?.role === 'admin'
|
||||
const [categories, setCategories] = useState([])
|
||||
const [skills, setSkills] = useState([])
|
||||
const [members, setMembers] = useState([])
|
||||
const [levels, setLevels] = useState({})
|
||||
const { categories } = useCategories()
|
||||
const { skills } = useSkills()
|
||||
const { members } = useMembers()
|
||||
const { levels, updateLevel } = useSkillLevels()
|
||||
|
||||
const [filterCat, setFilterCat] = useState('all')
|
||||
const [filterMember, setFilterMember] = useState('all')
|
||||
const [filterMinLevel, setFilterMinLevel] = useState(0)
|
||||
const [editing, setEditing] = useState(null)
|
||||
|
||||
useEffect(() => { load() }, [])
|
||||
|
||||
async function load() {
|
||||
const [catRes, skillRes, memberRes, levelRes] = await Promise.all([
|
||||
supabase.from('categories').select('*').order('name'),
|
||||
supabase.from('skills').select('*').order('name'),
|
||||
supabase.from('members').select('*').order('full_name'),
|
||||
supabase.from('skill_levels').select('*'),
|
||||
])
|
||||
if (catRes.data) setCategories(catRes.data)
|
||||
if (skillRes.data) setSkills(skillRes.data)
|
||||
if (memberRes.data) setMembers(memberRes.data)
|
||||
if (levelRes.data) {
|
||||
const map = {}
|
||||
levelRes.data.forEach((l) => {
|
||||
map[`${l.member_id}-${l.skill_id}`] = l
|
||||
})
|
||||
setLevels(map)
|
||||
}
|
||||
function onFilterChange(key, value) {
|
||||
if (key === 'cat') setFilterCat(value)
|
||||
if (key === 'member') setFilterMember(value)
|
||||
if (key === 'minLevel') setFilterMinLevel(value)
|
||||
}
|
||||
|
||||
const filteredSkills = filterCat === 'all'
|
||||
@@ -47,37 +35,17 @@ export function SkillMatrix() {
|
||||
? members
|
||||
: members.filter((m) => m.id === filterMember)
|
||||
|
||||
const filteredByLevel = filteredMembers.filter((m) => {
|
||||
if (filterMinLevel === 0) return true
|
||||
return filteredSkills.some((s) => {
|
||||
const key = `${m.id}-${s.id}`
|
||||
return (levels[key]?.level || 0) >= filterMinLevel
|
||||
})
|
||||
})
|
||||
const visibleMembers = filterMinLevel === 0
|
||||
? filteredMembers
|
||||
: filteredMembers.filter((m) =>
|
||||
filteredSkills.some((s) => {
|
||||
const key = `${m.id}-${s.id}`
|
||||
return (levels[key]?.level || 0) >= filterMinLevel
|
||||
})
|
||||
)
|
||||
|
||||
async function updateLevel(memberId, skillId, newLevel) {
|
||||
const key = `${memberId}-${skillId}`
|
||||
const existing = levels[key]
|
||||
const oldLevel = existing?.level
|
||||
|
||||
if (existing) {
|
||||
await supabase.from('skill_levels').update({ level: newLevel }).eq('id', existing.id)
|
||||
} else {
|
||||
await supabase.from('skill_levels').insert({ member_id: memberId, skill_id: skillId, level: newLevel })
|
||||
}
|
||||
|
||||
// Historique
|
||||
if (oldLevel !== newLevel) {
|
||||
await supabase.from('skill_history').insert({
|
||||
member_id: memberId,
|
||||
skill_id: skillId,
|
||||
old_level: oldLevel || null,
|
||||
new_level: newLevel,
|
||||
changed_by: profile.id,
|
||||
})
|
||||
}
|
||||
|
||||
load()
|
||||
async function handleUpdate(memberId, skillId, newLevel) {
|
||||
await updateLevel(memberId, skillId, newLevel, profile.id)
|
||||
setEditing(null)
|
||||
toast.success('Niveau mis à jour')
|
||||
}
|
||||
@@ -86,109 +54,27 @@ export function SkillMatrix() {
|
||||
<div className="space-y-6">
|
||||
<h1 className="text-2xl font-bold">Matrice des compétences</h1>
|
||||
|
||||
<div className="flex gap-4 flex-wrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm text-gray-600">Catégorie :</label>
|
||||
<select
|
||||
className="border rounded px-2 py-1 text-sm"
|
||||
value={filterCat}
|
||||
onChange={(e) => setFilterCat(e.target.value)}
|
||||
>
|
||||
<option value="all">Toutes</option>
|
||||
{categories.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm text-gray-600">Membre :</label>
|
||||
<select
|
||||
className="border rounded px-2 py-1 text-sm"
|
||||
value={filterMember}
|
||||
onChange={(e) => setFilterMember(e.target.value)}
|
||||
>
|
||||
<option value="all">Tous</option>
|
||||
{members.map((m) => (
|
||||
<option key={m.id} value={m.id}>{m.full_name || m.email}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm text-gray-600">Niveau min. :</label>
|
||||
<select
|
||||
className="border rounded px-2 py-1 text-sm"
|
||||
value={filterMinLevel}
|
||||
onChange={(e) => setFilterMinLevel(Number(e.target.value))}
|
||||
>
|
||||
<option value={0}>Aucun</option>
|
||||
{[1, 2, 3, 4].map((l) => <option key={l} value={l}>{l}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<SkillMatrixFilters
|
||||
categories={categories}
|
||||
members={members}
|
||||
filterCat={filterCat}
|
||||
filterMember={filterMember}
|
||||
filterMinLevel={filterMinLevel}
|
||||
onFilterChange={onFilterChange}
|
||||
/>
|
||||
|
||||
<div className="overflow-auto">
|
||||
<table className="w-full border-collapse">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="text-left p-2 bg-gray-100 border sticky left-0 z-10 min-w-[180px]">Membre</th>
|
||||
{filteredSkills.map((s) => (
|
||||
<th key={s.id} className="p-2 bg-gray-100 border text-sm text-center min-w-[120px]">{s.name}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredByLevel.length === 0 && (
|
||||
<tr><td colSpan={filteredSkills.length + 1} className="p-8 text-center text-gray-400">Aucun résultat</td></tr>
|
||||
)}
|
||||
{filteredByLevel.map((m) => (
|
||||
<tr key={m.id} className="hover:bg-gray-50">
|
||||
<td className="p-2 border font-medium sticky left-0 bg-white">
|
||||
<span className="text-sm">{m.full_name || m.email}</span>
|
||||
</td>
|
||||
{filteredSkills.map((s) => {
|
||||
const key = `${m.id}-${s.id}`
|
||||
const level = levels[key]
|
||||
const isEditing = editing === key
|
||||
return (
|
||||
<td key={s.id} className="p-2 border text-center">
|
||||
{isEditing && isAdmin ? (
|
||||
<SkillLevelSelect
|
||||
value={level?.level || 1}
|
||||
onChange={(v) => updateLevel(m.id, s.id, v)}
|
||||
/>
|
||||
) : (
|
||||
level ? (
|
||||
<SkillLevelBadge
|
||||
level={level.level}
|
||||
onClick={() => isAdmin && setEditing(key)}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className="text-gray-300 text-sm cursor-pointer"
|
||||
onClick={() => isAdmin && setEditing(key)}
|
||||
>
|
||||
—
|
||||
</span>
|
||||
)
|
||||
)}
|
||||
{isEditing && isAdmin && (
|
||||
<button
|
||||
className="ml-1 text-xs text-red-500"
|
||||
onClick={() => setEditing(null)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
)
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<SkillMatrixTable
|
||||
skills={filteredSkills}
|
||||
members={visibleMembers}
|
||||
levels={levels}
|
||||
isAdmin={isAdmin}
|
||||
editing={editing}
|
||||
onEdit={setEditing}
|
||||
onUpdate={handleUpdate}
|
||||
onCancel={() => setEditing(null)}
|
||||
/>
|
||||
|
||||
<div className="flex gap-4 text-sm text-gray-500">
|
||||
<div className="flex gap-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
<span><span className="inline-block w-3 h-3 rounded-full bg-gray-200 mr-1" /> Débutant</span>
|
||||
<span><span className="inline-block w-3 h-3 rounded-full bg-blue-200 mr-1" /> Intermédiaire</span>
|
||||
<span><span className="inline-block w-3 h-3 rounded-full bg-amber-200 mr-1" /> Avancé</span>
|
||||
|
||||
+36
-57
@@ -1,15 +1,18 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { supabase } from '@/lib/supabase'
|
||||
import { useCategories } from '@/hooks/useCategories'
|
||||
import { useSkills } from '@/hooks/useSkills'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||
import { CategoryCard } from '@/components/skills/CategoryCard'
|
||||
import { Search } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export function Skills() {
|
||||
const [categories, setCategories] = useState([])
|
||||
const [skills, setSkills] = useState([])
|
||||
const { categories, refetch: refetchCats } = useCategories()
|
||||
const { skills, refetch: refetchSkills } = useSkills()
|
||||
const [search, setSearch] = useState('')
|
||||
const [newCatName, setNewCatName] = useState('')
|
||||
const [newCatColor, setNewCatColor] = useState('#3b82f6')
|
||||
const [editCat, setEditCat] = useState(null)
|
||||
@@ -17,17 +20,6 @@ export function Skills() {
|
||||
const [catDialogOpen, setCatDialogOpen] = useState(false)
|
||||
const [skillDialogOpen, setSkillDialogOpen] = useState(false)
|
||||
|
||||
useEffect(() => { load() }, [])
|
||||
|
||||
async function load() {
|
||||
const [catRes, skillRes] = await Promise.all([
|
||||
supabase.from('categories').select('*').order('name'),
|
||||
supabase.from('skills').select('*, category:category_id(name)').order('name'),
|
||||
])
|
||||
if (catRes.data) setCategories(catRes.data)
|
||||
if (skillRes.data) setSkills(skillRes.data)
|
||||
}
|
||||
|
||||
async function saveCategory() {
|
||||
if (editCat) {
|
||||
await supabase.from('categories').update({ name: newCatName, color: newCatColor }).eq('id', editCat)
|
||||
@@ -37,28 +29,29 @@ export function Skills() {
|
||||
setCatDialogOpen(false)
|
||||
setEditCat(null)
|
||||
setNewCatName('')
|
||||
load()
|
||||
refetchCats()
|
||||
toast.success('Catégorie enregistrée')
|
||||
}
|
||||
|
||||
async function deleteCategory(id) {
|
||||
const { error } = await supabase.from('categories').delete().eq('id', id)
|
||||
if (error) toast.error(error.message)
|
||||
else { load(); toast.success('Catégorie supprimée') }
|
||||
else { refetchCats(); toast.success('Catégorie supprimée') }
|
||||
}
|
||||
|
||||
async function saveSkill() {
|
||||
await supabase.from('skills').insert({ name: newSkill.name, category_id: newSkill.category_id })
|
||||
setSkillDialogOpen(false)
|
||||
setNewSkill({ name: '', category_id: '' })
|
||||
load()
|
||||
refetchSkills()
|
||||
toast.success('Compétence ajoutée')
|
||||
}
|
||||
|
||||
async function deleteSkill(id) {
|
||||
await supabase.from('skills').delete().eq('id', id)
|
||||
load()
|
||||
toast.success('Compétence supprimée')
|
||||
function openEditCategory(cat) {
|
||||
setEditCat(cat.id)
|
||||
setNewCatName(cat.name)
|
||||
setNewCatColor(cat.color)
|
||||
setCatDialogOpen(true)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -73,7 +66,7 @@ export function Skills() {
|
||||
<div className="space-y-4">
|
||||
<Input placeholder="Nom" value={newSkill.name} onChange={(e) => setNewSkill({ ...newSkill, name: e.target.value })} />
|
||||
<select
|
||||
className="w-full border rounded-md px-3 py-2"
|
||||
className="w-full border rounded-md px-3 py-2 dark:bg-gray-800 dark:border-gray-700"
|
||||
value={newSkill.category_id}
|
||||
onChange={(e) => setNewSkill({ ...newSkill, category_id: e.target.value })}
|
||||
>
|
||||
@@ -99,41 +92,27 @@ export function Skills() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<Input
|
||||
className="pl-10"
|
||||
placeholder="Rechercher une compétence..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{categories.map((cat) => {
|
||||
const catSkills = skills.filter((s) => s.category_id === cat.id)
|
||||
const catSkills = skills.filter((s) => s.category_id === cat.id && (!search || s.name.toLowerCase().includes(search.toLowerCase())))
|
||||
if (search && catSkills.length === 0) return null
|
||||
return (
|
||||
<Card key={cat.id}>
|
||||
<CardHeader className="flex flex-row items-center justify-between py-3">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded-full" style={{ backgroundColor: cat.color }} />
|
||||
{cat.name}
|
||||
<Badge variant="secondary" className="ml-2">{catSkills.length}</Badge>
|
||||
</CardTitle>
|
||||
<div className="flex gap-1">
|
||||
<Button size="sm" variant="ghost" onClick={() => {
|
||||
setEditCat(cat.id)
|
||||
setNewCatName(cat.name)
|
||||
setNewCatColor(cat.color)
|
||||
setCatDialogOpen(true)
|
||||
}}>✎</Button>
|
||||
<Button size="sm" variant="ghost" onClick={() => deleteCategory(cat.id)}>🗑</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{catSkills.length === 0 ? (
|
||||
<p className="text-sm text-gray-400">Aucune compétence dans cette catégorie</p>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{catSkills.map((s) => (
|
||||
<Badge key={s.id} variant="outline" className="pr-1">
|
||||
{s.name}
|
||||
<button className="ml-1 text-gray-400 hover:text-red-500" onClick={() => deleteSkill(s.id)}>×</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<CategoryCard
|
||||
key={cat.id}
|
||||
category={cat}
|
||||
skills={catSkills}
|
||||
onEdit={openEditCategory}
|
||||
onDelete={deleteCategory}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user