Remaster avec skill supabase only et grillme

This commit is contained in:
2026-05-24 20:30:45 +02:00
parent e38a3df248
commit 2ee7decbfd
19 changed files with 734 additions and 369 deletions
+5 -1
View File
@@ -4,5 +4,9 @@
# Supabase API URL # Supabase API URL
VITE_SUPABASE_URL=http://localhost:8000 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 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
+31 -4
View File
@@ -11,27 +11,51 @@ npm run dev # Vite dev server with HMR
npm run build # Production build → dist/ npm run build # Production build → dist/
npm run lint # ESLint (flat config) npm run lint # ESLint (flat config)
npm run preview # Serve dist/ locally npm run preview # Serve dist/ locally
npm run test # Vitest run (hooks tests)
npm run test:watch# Vitest watch mode
``` ```
No test runner, no typecheck script. No typecheck script.
## Architecture ## Architecture
- **Entry**: `src/main.jsx``src/App.jsx` - **Entry**: `src/main.jsx` wrapped in `<ThemeProvider>` (next-themes) → `src/App.jsx`
- **Auth**: `src/context/AuthContext.jsx` — Supabase Auth + `members` table profile (role: admin/member) - **Auth**: `src/context/AuthContext.jsx` — Supabase Auth + `members` table profile (role: admin/member)
- **Routing**: `src/App.jsx` — public routes `/login`, `/register`, `/accept-invite`; protected routes wrapped in `<ProtectedRoute>`; admin-only routes (`/members`, `/skills`) also wrapped in `<AdminRoute>` - **Routing**: `src/App.jsx` — public routes `/login`, `/register`, `/accept-invite`; protected routes wrapped in `<ProtectedRoute>`; admin-only routes (`/members`, `/skills`) also wrapped in `<AdminRoute>` + `<Suspense>` (React.lazy code splitting)
- **Error handling**: `<ErrorBoundary>` wraps all protected routes
- **Pages**: `src/pages/` — Dashboard, Members, Skills, SkillMatrix, History, Profile - **Pages**: `src/pages/` — Dashboard, Members, Skills, SkillMatrix, History, Profile
- **UI**: `src/components/ui/` — shadcn components (radix-nova style, JS, no TSX) - **UI**: `src/components/ui/` — shadcn components (radix-nova style, JS, no TSX)
- **Supabase client**: `src/lib/supabase.js` — reads `VITE_SUPABASE_URL` / `VITE_SUPABASE_ANON_KEY` from env - **Supabase client**: `src/lib/supabase.js` — reads `VITE_SUPABASE_URL` / `VITE_SUPABASE_ANON_KEY` from env
- **Path alias**: `@/*``src/*` (configured in both `vite.config.js` and `jsconfig.json`) - **Path alias**: `@/*``src/*` (configured in both `vite.config.js` and `jsconfig.json`)
## Data Layer
### Custom hooks in `src/hooks/`
| Hook | Returns | Notes |
|---|---|---|
| `useMembers()` | `{ members, loading, refetch }` | Full-text search via GIN index |
| `useSkills()` | `{ skills, loading, refetch }` | Includes `category:category_id(name)` join |
| `useCategories()` | `{ categories, loading, refetch }` | Ordered by name |
| `useSkillLevels()` | `{ levels, loading, refetch, updateLevel, getAverageSkillRating }` | `levels` is a `{memberId-skillId → level}` map. Has real-time subscription |
| `useHistory()` | `{ history, loading, count, page, totalPages, filters, setFilter, nextPage, prevPage, refetch }` | Paginated (50/page), real-time on INSERT |
### Data fetching patterns
- Hooks call Supabase directly (no additional API layer)
- No global state management (hooks + local state only)
- Real-time subscriptions via `supabase.channel()` on `skill_levels` and `skill_history`
## Supabase ## Supabase
- DB schema + RLS policies in `supabase/migrations/001_init.sql` - DB schema + RLS policies in `supabase/migrations/001_init.sql`
- Tables: `categories`, `skills`, `members`, `level_descriptions`, `skill_levels`, `skill_history`, `invitations` - Tables: `categories`, `skills`, `members`, `level_descriptions`, `skill_levels`, `skill_history`, `invitations`
- `handle_new_user()` trigger auto-creates a `members` row on auth signup - `handle_new_user()` trigger auto-creates a `members` row on auth signup
- RLS: read-all for most tables, write restricted to admin role - RLS: read-all for most tables, write restricted to admin role
- `.env` points to a local Supabase instance (`192.168.2.220:8000`) — this is committed but `.env` is gitignored; adjust for your environment - UPDATE policies include `WITH CHECK` matching the `USING` clause
- `invitations_read_admin` policy filters `expires_at > now()`
- Full-text search enabled via GIN indexes on `skills.name` and `members.full_name`
- Realtime publication enabled on `skill_levels` and `skill_history`
- `.env` points to a local Supabase instance (`vm-docker5.home.arpa:8000`) — this is committed but `.env` is gitignored; adjust for your environment
## Docker ## Docker
@@ -53,3 +77,6 @@ Components land in `src/components/ui/`. Uses Lucide icons.
- ESLint flat config (`eslint.config.js`) — ignores `dist/` - ESLint flat config (`eslint.config.js`) — ignores `dist/`
- Tailwind v4 via `@tailwindcss/vite` plugin (no `tailwind.config.js`) - Tailwind v4 via `@tailwindcss/vite` plugin (no `tailwind.config.js`)
- French UI labels (application is in French) - French UI labels (application is in French)
- Dark mode support via `next-themes` with class strategy
- Layout: Lucide icons, responsive sidebar (hamburger on mobile)
- CSV exports for Members and Matrix pages
+11
View File
@@ -18,4 +18,15 @@ export default defineConfig([
parserOptions: { ecmaFeatures: { jsx: true } }, parserOptions: { ecmaFeatures: { jsx: true } },
}, },
}, },
{
files: ['src/components/ui/**'],
rules: {
'no-unused-vars': 'off',
'react-refresh/only-export-components': 'off',
},
},
{
files: ['vite.config.js'],
rules: { 'no-undef': 'off' },
},
]) ])
+362 -3
View File
@@ -1,11 +1,11 @@
{ {
"name": "gestiondescmpetences", "name": "gestiondescompetences",
"version": "0.0.0", "version": "0.0.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "gestiondescmpetences", "name": "gestiondescompetences",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"@fontsource-variable/geist": "^5.2.9", "@fontsource-variable/geist": "^5.2.9",
@@ -36,7 +36,8 @@
"eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0", "globals": "^17.6.0",
"tailwindcss": "^4.3.0", "tailwindcss": "^4.3.0",
"vite": "^8.0.12" "vite": "^8.0.12",
"vitest": "^4.1.7"
} }
}, },
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
@@ -3017,6 +3018,13 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/@supabase/auth-js": {
"version": "2.105.4", "version": "2.105.4",
"resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.105.4.tgz", "resolved": "https://registry.npmjs.org/@supabase/auth-js/-/auth-js-2.105.4.tgz",
@@ -3395,6 +3403,24 @@
"tslib": "^2.4.0" "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": { "node_modules/@types/esrecurse": {
"version": "4.3.1", "version": "4.3.1",
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", "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": { "node_modules/accepts": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
@@ -3638,6 +3777,16 @@
"node": ">=10" "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": { "node_modules/ast-types": {
"version": "0.16.1", "version": "0.16.1",
"resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz", "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.16.1.tgz",
@@ -3834,6 +3983,16 @@
], ],
"license": "CC-BY-4.0" "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": { "node_modules/chalk": {
"version": "5.6.2", "version": "5.6.2",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
@@ -4367,6 +4526,13 @@
"node": ">= 0.4" "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": { "node_modules/es-object-atoms": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
@@ -4592,6 +4758,16 @@
"node": ">=4.0" "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": { "node_modules/esutils": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
@@ -4658,6 +4834,16 @@
"url": "https://github.com/sindresorhus/execa?sponsor=1" "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": { "node_modules/express": {
"version": "5.2.1", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz",
@@ -6405,6 +6591,17 @@
"node": ">= 10" "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": { "node_modules/on-finished": {
"version": "2.4.1", "version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@@ -6622,6 +6819,13 @@
"integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==",
"license": "MIT" "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": { "node_modules/pg": {
"version": "8.21.0", "version": "8.21.0",
"resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz", "resolved": "https://registry.npmjs.org/pg/-/pg-8.21.0.tgz",
@@ -7559,6 +7763,13 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/signal-exit": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
@@ -7614,6 +7825,13 @@
"node": ">= 10.x" "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": { "node_modules/statuses": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@ -7623,6 +7841,13 @@
"node": ">= 0.8" "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": { "node_modules/stdin-discarder": {
"version": "0.2.2", "version": "0.2.2",
"resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.2.2.tgz", "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==", "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT" "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": { "node_modules/tinyglobby": {
"version": "0.2.16", "version": "0.2.16",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz",
@@ -7777,6 +8019,16 @@
"url": "https://github.com/sponsors/SuperchupuDev" "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": { "node_modules/tldts": {
"version": "7.0.30", "version": "7.0.30",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.30.tgz", "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": { "node_modules/web-streams-polyfill": {
"version": "3.3.3", "version": "3.3.3",
"resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz",
@@ -8189,6 +8531,23 @@
"node": ">= 8" "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": { "node_modules/word-wrap": {
"version": "1.2.5", "version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
+7 -4
View File
@@ -1,5 +1,5 @@
{ {
"name": "gestiondescmpetences", "name": "gestiondescompetences",
"private": true, "private": true,
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",
@@ -7,7 +7,9 @@
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview" "preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
}, },
"dependencies": { "dependencies": {
"@fontsource-variable/geist": "^5.2.9", "@fontsource-variable/geist": "^5.2.9",
@@ -16,7 +18,6 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"lucide-react": "^1.16.0", "lucide-react": "^1.16.0",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"pg": "^8.21.0",
"radix-ui": "^1.4.3", "radix-ui": "^1.4.3",
"react": "^19.2.6", "react": "^19.2.6",
"react-dom": "^19.2.6", "react-dom": "^19.2.6",
@@ -38,6 +39,8 @@
"eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.6.0", "globals": "^17.6.0",
"tailwindcss": "^4.3.0", "tailwindcss": "^4.3.0",
"vite": "^8.0.12" "vite": "^8.0.12",
"pg": "^8.21.0",
"vitest": "^4.1.7"
} }
} }
+19 -6
View File
@@ -1,11 +1,24 @@
import { readFileSync } from 'fs' import { readFileSync } from 'fs'
import { resolve, dirname } from 'path'
import { fileURLToPath } from 'url'
const SUPABASE_URL = 'http://192.168.2.220:8000' const __dirname = dirname(fileURLToPath(import.meta.url))
const ANON_KEY = readFileSync('/home/tophe/10-Projets/DevOps/OpenCode/GestionDesCompetences/.env', 'utf8') const envPath = resolve(__dirname, '..', '.env')
.split('\n') const envContent = readFileSync(envPath, 'utf8')
.find(l => l.startsWith('VITE_SUPABASE_ANON_KEY='))
?.split('=').slice(1).join('=') function readEnv(key) {
const SERVICE_ROLE_KEY = 'eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImJmMzI4YWViLTYwMWYtNGEzZC04MjdiLTY1MTZlZTY0MWViMyJ9.eyJyb2xlIjoic2VydmljZV9yb2xlIiwiaXNzIjoic3VwYWJhc2UiLCJpYXQiOjE3NzkzMDAyNDMsImV4cCI6MTkzNjk4MDI0M30.qt2IKVgwaQQkHIZVWH4tEcrozU0mT3F9dNC9Yo83UidKwsoxHRqZz8hBWjreRPsThUcCgjxOmhwxeTB7Zd7RFA' const line = envContent.split('\n').find(l => l.startsWith(`${key}=`))
return line?.split('=').slice(1).join('=')?.trim()
}
const SUPABASE_URL = readEnv('VITE_SUPABASE_URL')
const ANON_KEY = readEnv('VITE_SUPABASE_ANON_KEY')
const SERVICE_ROLE_KEY = readEnv('VITE_SUPABASE_SERVICE_ROLE_KEY')
if (!SUPABASE_URL || !ANON_KEY || !SERVICE_ROLE_KEY) {
console.error('Missing required env vars in .env. Need VITE_SUPABASE_URL, VITE_SUPABASE_ANON_KEY, VITE_SUPABASE_SERVICE_ROLE_KEY')
process.exit(1)
}
const headers = { const headers = {
'apikey': ANON_KEY, 'apikey': ANON_KEY,
+6
View File
@@ -1,6 +1,12 @@
{ {
"version": 1, "version": 1,
"skills": { "skills": {
"grill-me": {
"source": "mattpocock/skills",
"sourceType": "github",
"skillPath": "skills/productivity/grill-me/SKILL.md",
"computedHash": "784f0dbb7403b0f00324bce9a112f715342777a0daee7bbb7385f9c6f0a170ea"
},
"pptx": { "pptx": {
"source": "anthropics/skills", "source": "anthropics/skills",
"sourceType": "github", "sourceType": "github",
+42 -27
View File
@@ -1,21 +1,30 @@
import { lazy, Suspense } from 'react'
import { BrowserRouter, Routes, Route } from 'react-router-dom' import { BrowserRouter, Routes, Route } from 'react-router-dom'
import { AuthProvider } from '@/context/AuthContext' import { AuthProvider } from '@/context/AuthContext'
import { ProtectedRoute, AdminRoute } from '@/components/ProtectedRoute' import { ProtectedRoute, AdminRoute } from '@/components/ProtectedRoute'
import { ErrorBoundary } from '@/components/ErrorBoundary'
import { Layout } from '@/components/Layout' import { Layout } from '@/components/Layout'
import { Toaster } from 'sonner' import { Toaster } from 'sonner'
import { Login } from '@/pages/Login' import { Login } from '@/pages/Login'
import { Register } from '@/pages/Register' import { Register } from '@/pages/Register'
import { AcceptInvite } from '@/pages/AcceptInvite' import { AcceptInvite } from '@/pages/AcceptInvite'
import { Dashboard } from '@/pages/Dashboard' import { Dashboard } from '@/pages/Dashboard'
import { Members } from '@/pages/Members'
import { Skills } from '@/pages/Skills'
import { SkillMatrix } from '@/pages/SkillMatrix' import { SkillMatrix } from '@/pages/SkillMatrix'
import { History } from '@/pages/History' import { History } from '@/pages/History'
import { Profile } from '@/pages/Profile' import { Profile } from '@/pages/Profile'
const Members = lazy(() => import('@/pages/Members').then(m => ({ default: m.Members })))
const Skills = lazy(() => import('@/pages/Skills').then(m => ({ default: m.Skills })))
function AppLayout({ children }) { function AppLayout({ children }) {
return <Layout>{children}</Layout>
}
function SuspenseWrapper({ children }) {
return ( return (
<Layout>{children}</Layout> <Suspense fallback={<div className="flex items-center justify-center min-h-[60vh] text-gray-400">Chargement...</div>}>
{children}
</Suspense>
) )
} }
@@ -23,32 +32,38 @@ export default function App() {
return ( return (
<BrowserRouter> <BrowserRouter>
<AuthProvider> <AuthProvider>
<Toaster /> <ErrorBoundary>
<Routes> <Toaster />
<Route path="/login" element={<Login />} /> <Routes>
<Route path="/register" element={<Register />} /> <Route path="/login" element={<Login />} />
<Route path="/accept-invite" element={<AcceptInvite />} /> <Route path="/register" element={<Register />} />
<Route path="/accept-invite" element={<AcceptInvite />} />
<Route path="/" element={ <Route path="/" element={
<ProtectedRoute><AppLayout><Dashboard /></AppLayout></ProtectedRoute> <ProtectedRoute><AppLayout><Dashboard /></AppLayout></ProtectedRoute>
} /> } />
<Route path="/matrix" element={ <Route path="/matrix" element={
<ProtectedRoute><AppLayout><SkillMatrix /></AppLayout></ProtectedRoute> <ProtectedRoute><AppLayout><SkillMatrix /></AppLayout></ProtectedRoute>
} /> } />
<Route path="/history" element={ <Route path="/history" element={
<ProtectedRoute><AppLayout><History /></AppLayout></ProtectedRoute> <ProtectedRoute><AppLayout><History /></AppLayout></ProtectedRoute>
} /> } />
<Route path="/profile" element={ <Route path="/profile" element={
<ProtectedRoute><AppLayout><Profile /></AppLayout></ProtectedRoute> <ProtectedRoute><AppLayout><Profile /></AppLayout></ProtectedRoute>
} /> } />
<Route path="/members" element={ <Route path="/members" element={
<ProtectedRoute><AdminRoute><AppLayout><Members /></AppLayout></AdminRoute></ProtectedRoute> <ProtectedRoute><AdminRoute><AppLayout>
} /> <SuspenseWrapper><Members /></SuspenseWrapper>
<Route path="/skills" element={ </AppLayout></AdminRoute></ProtectedRoute>
<ProtectedRoute><AdminRoute><AppLayout><Skills /></AppLayout></AdminRoute></ProtectedRoute> } />
} /> <Route path="/skills" element={
</Routes> <ProtectedRoute><AdminRoute><AppLayout>
<SuspenseWrapper><Skills /></SuspenseWrapper>
</AppLayout></AdminRoute></ProtectedRoute>
} />
</Routes>
</ErrorBoundary>
</AuthProvider> </AuthProvider>
</BrowserRouter> </BrowserRouter>
) )
+83 -40
View File
@@ -1,3 +1,4 @@
import { useState } from 'react'
import { Link, useLocation, useNavigate } from 'react-router-dom' import { Link, useLocation, useNavigate } from 'react-router-dom'
import { useAuth } from '@/context/AuthContext' import { useAuth } from '@/context/AuthContext'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
@@ -5,75 +6,117 @@ import { Avatar, AvatarFallback } from '@/components/ui/avatar'
import { import {
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu' } from '@/components/ui/dropdown-menu'
import {
LayoutDashboard, BookOpen, Users, Table2, History, Menu, X, Sun, Moon, LogOut, User,
} from 'lucide-react'
import { useTheme } from 'next-themes'
const navItems = [ const navItems = [
{ to: '/', label: 'Tableau de bord', icon: '📊' }, { to: '/', label: 'Tableau de bord', icon: LayoutDashboard },
{ to: '/skills', label: 'Compétences', icon: '📚', admin: true }, { to: '/skills', label: 'Compétences', icon: BookOpen, admin: true },
{ to: '/members', label: 'Membres', icon: '👥', admin: true }, { to: '/members', label: 'Membres', icon: Users, admin: true },
{ to: '/matrix', label: 'Matrice', icon: '📋' }, { to: '/matrix', label: 'Matrice', icon: Table2 },
{ to: '/history', label: 'Historique', icon: '📜' }, { to: '/history', label: 'Historique', icon: History },
] ]
export function Layout({ children }) { export function Layout({ children }) {
const { profile, signOut } = useAuth() const { profile, signOut } = useAuth()
const { theme, setTheme } = useTheme()
const location = useLocation() const location = useLocation()
const navigate = useNavigate() const navigate = useNavigate()
const [sidebarOpen, setSidebarOpen] = useState(false)
async function handleSignOut() { async function handleSignOut() {
await signOut() await signOut()
navigate('/login') navigate('/login')
} }
return ( const sidebar = (
<div className="min-h-screen flex"> <aside className={`w-64 bg-gray-900 text-white flex flex-col shrink-0 ${sidebarOpen ? 'fixed inset-0 z-50' : 'hidden lg:flex'}`}>
<aside className="w-64 bg-gray-900 text-white flex flex-col"> <div className="p-4 border-b border-gray-700 flex items-center justify-between">
<div className="p-4 border-b border-gray-700"> <div>
<h1 className="text-lg font-bold">Compétences</h1> <h1 className="text-lg font-bold">Compétences</h1>
<p className="text-xs text-gray-400">Équipe SysAdmin</p> <p className="text-xs text-gray-400">Équipe SysAdmin</p>
</div> </div>
<nav className="flex-1 p-2 space-y-1"> {sidebarOpen && (
{navItems <button className="lg:hidden text-gray-400 hover:text-white" onClick={() => setSidebarOpen(false)}>
.filter((item) => !item.admin || profile?.role === 'admin') <X className="h-5 w-5" />
.map((item) => ( </button>
)}
</div>
<nav className="flex-1 p-2 space-y-1">
{navItems
.filter((item) => !item.admin || profile?.role === 'admin')
.map((item) => {
const Icon = item.icon
return (
<Link <Link
key={item.to} key={item.to}
to={item.to} to={item.to}
onClick={() => setSidebarOpen(false)}
className={`flex items-center gap-3 px-3 py-2 rounded-md text-sm transition-colors ${ className={`flex items-center gap-3 px-3 py-2 rounded-md text-sm transition-colors ${
location.pathname === item.to location.pathname === item.to
? 'bg-gray-700 text-white' ? 'bg-gray-700 text-white'
: 'text-gray-300 hover:bg-gray-800 hover:text-white' : 'text-gray-300 hover:bg-gray-800 hover:text-white'
}`} }`}
> >
<span>{item.icon}</span> <Icon className="h-4 w-4" />
{item.label} {item.label}
</Link> </Link>
))} )
</nav> })}
<div className="p-4 border-t border-gray-700"> </nav>
<DropdownMenu> <div className="p-4 border-t border-gray-700 space-y-2">
<DropdownMenuTrigger asChild> <button
<Button variant="ghost" className="w-full flex items-center gap-2 text-gray-300 hover:text-white"> className="flex items-center gap-2 text-sm text-gray-400 hover:text-white w-full"
<Avatar className="h-6 w-6"> onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
<AvatarFallback className="text-xs"> >
{profile?.full_name?.charAt(0)?.toUpperCase() || '?'} {theme === 'dark' ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</AvatarFallback> {theme === 'dark' ? 'Mode clair' : 'Mode sombre'}
</Avatar> </button>
<span className="text-sm truncate">{profile?.full_name || profile?.email}</span> <DropdownMenu>
</Button> <DropdownMenuTrigger asChild>
</DropdownMenuTrigger> <Button variant="ghost" className="w-full flex items-center gap-2 text-gray-300 hover:text-white">
<DropdownMenuContent align="end" className="w-48"> <Avatar className="h-6 w-6">
<DropdownMenuItem onClick={() => navigate('/profile')}> <AvatarFallback className="text-xs">
Mon profil {profile?.full_name?.charAt(0)?.toUpperCase() || '?'}
</DropdownMenuItem> </AvatarFallback>
<DropdownMenuItem onClick={handleSignOut}> </Avatar>
Déconnexion <span className="text-sm truncate">{profile?.full_name || profile?.email}</span>
</DropdownMenuItem> </Button>
</DropdownMenuContent> </DropdownMenuTrigger>
</DropdownMenu> <DropdownMenuContent align="end" className="w-48">
<DropdownMenuItem onClick={() => navigate('/profile')}>
<User className="h-4 w-4 mr-2" />
Mon profil
</DropdownMenuItem>
<DropdownMenuItem onClick={handleSignOut}>
<LogOut className="h-4 w-4 mr-2" />
Déconnexion
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</aside>
)
return (
<div className="min-h-screen flex bg-gray-50 dark:bg-gray-950 dark:text-gray-100">
{sidebarOpen && (
<div className="fixed inset-0 bg-black/50 z-40 lg:hidden" onClick={() => setSidebarOpen(false)} />
)}
{sidebar}
<main className="flex-1 flex flex-col min-w-0">
<div className="lg:hidden flex items-center justify-between p-4 border-b bg-white dark:bg-gray-900 dark:border-gray-800">
<button onClick={() => setSidebarOpen(true)} className="text-gray-700 dark:text-gray-300">
<Menu className="h-5 w-5" />
</button>
<span className="font-bold text-sm">Compétences</span>
<div className="w-5" />
</div>
<div className="p-4 md:p-8 overflow-auto">
{children}
</div> </div>
</aside>
<main className="flex-1 bg-gray-50 p-8 overflow-auto">
{children}
</main> </main>
</div> </div>
) )
+1
View File
@@ -1,3 +1,4 @@
/* eslint-disable react-refresh/only-export-components */
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
const levelConfig = { const levelConfig = {
+1
View File
@@ -1,3 +1,4 @@
/* eslint-disable react-refresh/only-export-components */
import { createContext, useContext, useEffect, useState } from 'react' import { createContext, useContext, useEffect, useState } from 'react'
import { supabase } from '@/lib/supabase' import { supabase } from '@/lib/supabase'
+4 -1
View File
@@ -1,10 +1,13 @@
import { StrictMode } from 'react' import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client' import { createRoot } from 'react-dom/client'
import { ThemeProvider } from 'next-themes'
import './index.css' import './index.css'
import App from './App.jsx' import App from './App.jsx'
createRoot(document.getElementById('root')).render( createRoot(document.getElementById('root')).render(
<StrictMode> <StrictMode>
<App /> <ThemeProvider attribute="class" defaultTheme="system" enableSystem>
<App />
</ThemeProvider>
</StrictMode>, </StrictMode>,
) )
+15 -9
View File
@@ -16,29 +16,36 @@ export function AcceptInvite() {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [valid, setValid] = useState(false) const [valid, setValid] = useState(false)
/* eslint-disable react-hooks/set-state-in-effect */
useEffect(() => { useEffect(() => {
let cancelled = false
async function checkToken() { async function checkToken() {
const { data, error } = await supabase const { data } = await supabase
.from('invitations') .from('invitations')
.select('*') .select('*')
.eq('token', token) .eq('token', token)
.eq('accepted', false) .eq('accepted', false)
.gte('expires_at', new Date().toISOString())
.single() .single()
if (data && !error) { if (!cancelled) {
setEmail(data.email) if (data) {
setValid(true) setEmail(data.email)
setValid(true)
}
setLoading(false)
} }
setLoading(false)
} }
if (token) checkToken() if (token) checkToken()
else setLoading(false) else setLoading(false)
return () => { cancelled = true }
}, [token]) }, [token])
/* eslint-enable react-hooks/set-state-in-effect */
async function handleSubmit(e) { async function handleSubmit(e) {
e.preventDefault() e.preventDefault()
setLoading(true) setLoading(true)
const { data, error } = await supabase.auth.signUp({ const { error } = await supabase.auth.signUp({
email, email,
password, password,
options: { data: { full_name: name } }, options: { data: { full_name: name } },
@@ -50,7 +57,6 @@ export function AcceptInvite() {
return return
} }
// Marquer l'invitation comme acceptée
await supabase.from('invitations').update({ accepted: true }).eq('token', token) await supabase.from('invitations').update({ accepted: true }).eq('token', token)
toast.success('Compte créé ! Vous pouvez vous connecter.') toast.success('Compte créé ! Vous pouvez vous connecter.')
@@ -69,11 +75,11 @@ export function AcceptInvite() {
) )
return ( return (
<div className="min-h-screen flex items-center justify-center bg-gray-100"> <div className="min-h-screen flex items-center justify-center bg-gray-100 dark:bg-gray-950">
<Card className="w-full max-w-md"> <Card className="w-full max-w-md">
<CardHeader> <CardHeader>
<CardTitle className="text-2xl text-center">Accepter l'invitation</CardTitle> <CardTitle className="text-2xl text-center">Accepter l'invitation</CardTitle>
<p className="text-sm text-gray-500 text-center mt-1">{email}</p> <p className="text-sm text-gray-500 dark:text-gray-400 text-center mt-1">{email}</p>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
+5 -6
View File
@@ -29,7 +29,6 @@ export function Dashboard() {
}) })
setRecentChanges(history.data || []) setRecentChanges(history.data || [])
// Compétences les mieux notées (moyenne)
const { data: levels } = await supabase const { data: levels } = await supabase
.from('skill_levels') .from('skill_levels')
.select('skill_id, level, skill:skill_id(name)') .select('skill_id, level, skill:skill_id(name)')
@@ -54,7 +53,7 @@ export function Dashboard() {
<div className="space-y-6"> <div className="space-y-6">
<h1 className="text-2xl font-bold">Tableau de bord</h1> <h1 className="text-2xl font-bold">Tableau de bord</h1>
<div className="grid grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card> <Card>
<CardHeader><CardTitle className="text-lg">Membres</CardTitle></CardHeader> <CardHeader><CardTitle className="text-lg">Membres</CardTitle></CardHeader>
<CardContent><p className="text-3xl font-bold">{stats.members}</p></CardContent> <CardContent><p className="text-3xl font-bold">{stats.members}</p></CardContent>
@@ -69,12 +68,12 @@ export function Dashboard() {
</Card> </Card>
</div> </div>
<div className="grid grid-cols-2 gap-6"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<Card> <Card>
<CardHeader><CardTitle className="text-lg">Compétences les mieux notées</CardTitle></CardHeader> <CardHeader><CardTitle className="text-lg">Compétences les mieux notées</CardTitle></CardHeader>
<CardContent> <CardContent>
{topSkills.length === 0 ? ( {topSkills.length === 0 ? (
<p className="text-gray-500">Aucune évaluation pour le moment</p> <p className="text-gray-500 dark:text-gray-400">Aucune évaluation pour le moment</p>
) : ( ) : (
<ul className="space-y-2"> <ul className="space-y-2">
{topSkills.map((s) => ( {topSkills.map((s) => (
@@ -92,11 +91,11 @@ export function Dashboard() {
<CardHeader><CardTitle className="text-lg">Dernières évolutions</CardTitle></CardHeader> <CardHeader><CardTitle className="text-lg">Dernières évolutions</CardTitle></CardHeader>
<CardContent className="max-h-80 overflow-auto"> <CardContent className="max-h-80 overflow-auto">
{recentChanges.length === 0 ? ( {recentChanges.length === 0 ? (
<p className="text-gray-500">Aucun changement récent</p> <p className="text-gray-500 dark:text-gray-400">Aucun changement récent</p>
) : ( ) : (
<ul className="space-y-3"> <ul className="space-y-3">
{recentChanges.map((c) => ( {recentChanges.map((c) => (
<li key={c.id} className="text-sm border-b pb-2 last:border-0"> <li key={c.id} className="text-sm border-b dark:border-gray-800 pb-2 last:border-0">
<span className="font-medium">{c.member?.full_name}</span> <span className="font-medium">{c.member?.full_name}</span>
{' '}a mis à jour{' '} {' '}a mis à jour{' '}
<span className="font-medium">{c.skill?.name}</span> <span className="font-medium">{c.skill?.name}</span>
+34 -47
View File
@@ -1,44 +1,15 @@
import { useEffect, useState } from 'react' import { useMembers } from '@/hooks/useMembers'
import { supabase } from '@/lib/supabase' import { useSkills } from '@/hooks/useSkills'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { useHistory } from '@/hooks/useHistory'
import { Card, CardContent } from '@/components/ui/card'
import { SkillLevelBadge } from '@/components/SkillLevelBadge' import { SkillLevelBadge } from '@/components/SkillLevelBadge'
import { Input } from '@/components/ui/input' import { Button } from '@/components/ui/button'
import { ChevronLeft, ChevronRight } from 'lucide-react'
export function History() { export function History() {
const [history, setHistory] = useState([]) const { members } = useMembers()
const [members, setMembers] = useState([]) const { skills } = useSkills()
const [skills, setSkills] = useState([]) const { history, loading, count, page, totalPages, filters, setFilter, nextPage, prevPage } = useHistory()
const [filterMember, setFilterMember] = useState('all')
const [filterSkill, setFilterSkill] = useState('all')
useEffect(() => {
async function load() {
const [memRes, skillRes] = await Promise.all([
supabase.from('members').select('*').order('full_name'),
supabase.from('skills').select('*').order('name'),
])
if (memRes.data) setMembers(memRes.data)
if (skillRes.data) setSkills(skillRes.data)
}
load()
}, [])
useEffect(() => {
async function loadHistory() {
let query = supabase
.from('skill_history')
.select('*, member:member_id(full_name), skill:skill_id(name), changer:changed_by(full_name)')
.order('created_at', { ascending: false })
.limit(100)
if (filterMember !== 'all') query = query.eq('member_id', filterMember)
if (filterSkill !== 'all') query = query.eq('skill_id', filterSkill)
const { data } = await query
if (data) setHistory(data)
}
loadHistory()
}, [filterMember, filterSkill])
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -46,9 +17,9 @@ export function History() {
<div className="flex gap-4"> <div className="flex gap-4">
<select <select
className="border rounded px-2 py-1 text-sm" className="border rounded px-2 py-1 text-sm dark:bg-gray-800 dark:border-gray-700"
value={filterMember} value={filters.memberId}
onChange={(e) => setFilterMember(e.target.value)} onChange={(e) => setFilter('memberId', e.target.value)}
> >
<option value="all">Tous les membres</option> <option value="all">Tous les membres</option>
{members.map((m) => ( {members.map((m) => (
@@ -56,9 +27,9 @@ export function History() {
))} ))}
</select> </select>
<select <select
className="border rounded px-2 py-1 text-sm" className="border rounded px-2 py-1 text-sm dark:bg-gray-800 dark:border-gray-700"
value={filterSkill} value={filters.skillId}
onChange={(e) => setFilterSkill(e.target.value)} onChange={(e) => setFilter('skillId', e.target.value)}
> >
<option value="all">Toutes les compétences</option> <option value="all">Toutes les compétences</option>
{skills.map((s) => ( {skills.map((s) => (
@@ -69,10 +40,12 @@ export function History() {
<Card> <Card>
<CardContent className="p-0"> <CardContent className="p-0">
{history.length === 0 ? ( {loading ? (
<p className="p-6 text-gray-400">Chargement...</p>
) : history.length === 0 ? (
<p className="p-6 text-gray-400">Aucun historique</p> <p className="p-6 text-gray-400">Aucun historique</p>
) : ( ) : (
<ul className="divide-y"> <ul className="divide-y dark:divide-gray-800">
{history.map((h) => ( {history.map((h) => (
<li key={h.id} className="p-4 flex items-center justify-between"> <li key={h.id} className="p-4 flex items-center justify-between">
<div className="space-y-1"> <div className="space-y-1">
@@ -80,7 +53,7 @@ export function History() {
<span className="font-medium">{h.member?.full_name}</span> <span className="font-medium">{h.member?.full_name}</span>
{' → '}<span className="font-medium">{h.skill?.name}</span> {' → '}<span className="font-medium">{h.skill?.name}</span>
</p> </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()} Par {h.changer?.full_name} {new Date(h.created_at).toLocaleString()}
</p> </p>
</div> </div>
@@ -95,6 +68,20 @@ export function History() {
)} )}
</CardContent> </CardContent>
</Card> </Card>
{totalPages > 1 && (
<div className="flex items-center justify-between text-sm text-gray-500 dark:text-gray-400">
<span>{count} entrées Page {page + 1} / {totalPages}</span>
<div className="flex gap-2">
<Button variant="outline" size="sm" onClick={prevPage} disabled={page === 0}>
<ChevronLeft className="h-4 w-4" /> Précédent
</Button>
<Button variant="outline" size="sm" onClick={nextPage} disabled={page >= totalPages - 1}>
Suivant <ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</div> </div>
) )
} }
+24 -2
View File
@@ -4,10 +4,11 @@ import { useAuth } from '@/context/AuthContext'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table' import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { InviteUserModal } from '@/components/InviteUserModal' import { InviteUserModal } from '@/components/InviteUserModal'
import { Download } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
export function Members() { export function Members() {
@@ -37,11 +38,32 @@ export function Members() {
toast.success('Membre supprimé') toast.success('Membre supprimé')
} }
function exportCSV() {
const header = 'Nom,Email,Rôle,Inscrit le\n'
const rows = members.map((m) =>
`"${m.full_name || ''}","${m.email}","${m.role}","${new Date(m.created_at).toLocaleDateString()}"`
).join('\n')
const blob = new Blob([header + rows], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'membres.csv'
a.click()
URL.revokeObjectURL(url)
toast.success('Fichier CSV téléchargé')
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Membres</h1> <h1 className="text-2xl font-bold">Membres</h1>
<InviteUserModal /> <div className="flex gap-2">
<Button variant="outline" onClick={exportCSV}>
<Download className="h-4 w-4 mr-2" />
CSV
</Button>
<InviteUserModal />
</div>
</div> </div>
<Card> <Card>
+3 -3
View File
@@ -27,15 +27,15 @@ export function Profile() {
<CardHeader><CardTitle>Informations</CardTitle></CardHeader> <CardHeader><CardTitle>Informations</CardTitle></CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div> <div>
<label className="text-sm text-gray-600">Email</label> <label className="text-sm text-gray-600 dark:text-gray-400">Email</label>
<p className="font-medium">{profile?.email}</p> <p className="font-medium">{profile?.email}</p>
</div> </div>
<div> <div>
<label className="text-sm text-gray-600">Rôle</label> <label className="text-sm text-gray-600 dark:text-gray-400">Rôle</label>
<p className="font-medium capitalize">{profile?.role}</p> <p className="font-medium capitalize">{profile?.role}</p>
</div> </div>
<div> <div>
<label className="text-sm text-gray-600">Nom complet</label> <label className="text-sm text-gray-600 dark:text-gray-400">Nom complet</label>
<Input value={name} onChange={(e) => setName(e.target.value)} /> <Input value={name} onChange={(e) => setName(e.target.value)} />
</div> </div>
<Button onClick={handleSave}>Enregistrer</Button> <Button onClick={handleSave}>Enregistrer</Button>
+45 -159
View File
@@ -1,42 +1,30 @@
import { useEffect, useState, useCallback } from 'react' import { useState } from 'react'
import { supabase } from '@/lib/supabase'
import { useAuth } from '@/context/AuthContext' import { useAuth } from '@/context/AuthContext'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { useCategories } from '@/hooks/useCategories'
import { SkillLevelBadge, SkillLevelSelect } from '@/components/SkillLevelBadge' import { useSkills } from '@/hooks/useSkills'
import { Input } from '@/components/ui/input' import { useMembers } from '@/hooks/useMembers'
import { useSkillLevels } from '@/hooks/useSkillLevels'
import { SkillMatrixFilters } from '@/components/matrix/SkillMatrixFilters'
import { SkillMatrixTable } from '@/components/matrix/SkillMatrixTable'
import { toast } from 'sonner' import { toast } from 'sonner'
export function SkillMatrix() { export function SkillMatrix() {
const { profile } = useAuth() const { profile } = useAuth()
const isAdmin = profile?.role === 'admin' const isAdmin = profile?.role === 'admin'
const [categories, setCategories] = useState([]) const { categories } = useCategories()
const [skills, setSkills] = useState([]) const { skills } = useSkills()
const [members, setMembers] = useState([]) const { members } = useMembers()
const [levels, setLevels] = useState({}) const { levels, updateLevel } = useSkillLevels()
const [filterCat, setFilterCat] = useState('all') const [filterCat, setFilterCat] = useState('all')
const [filterMember, setFilterMember] = useState('all') const [filterMember, setFilterMember] = useState('all')
const [filterMinLevel, setFilterMinLevel] = useState(0) const [filterMinLevel, setFilterMinLevel] = useState(0)
const [editing, setEditing] = useState(null) const [editing, setEditing] = useState(null)
useEffect(() => { load() }, []) function onFilterChange(key, value) {
if (key === 'cat') setFilterCat(value)
async function load() { if (key === 'member') setFilterMember(value)
const [catRes, skillRes, memberRes, levelRes] = await Promise.all([ if (key === 'minLevel') setFilterMinLevel(value)
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)
}
} }
const filteredSkills = filterCat === 'all' const filteredSkills = filterCat === 'all'
@@ -47,37 +35,17 @@ export function SkillMatrix() {
? members ? members
: members.filter((m) => m.id === filterMember) : members.filter((m) => m.id === filterMember)
const filteredByLevel = filteredMembers.filter((m) => { const visibleMembers = filterMinLevel === 0
if (filterMinLevel === 0) return true ? filteredMembers
return filteredSkills.some((s) => { : filteredMembers.filter((m) =>
const key = `${m.id}-${s.id}` filteredSkills.some((s) => {
return (levels[key]?.level || 0) >= filterMinLevel const key = `${m.id}-${s.id}`
}) return (levels[key]?.level || 0) >= filterMinLevel
}) })
)
async function updateLevel(memberId, skillId, newLevel) { async function handleUpdate(memberId, skillId, newLevel) {
const key = `${memberId}-${skillId}` await updateLevel(memberId, skillId, newLevel, profile.id)
const existing = levels[key]
const oldLevel = existing?.level
if (existing) {
await supabase.from('skill_levels').update({ level: newLevel }).eq('id', existing.id)
} else {
await supabase.from('skill_levels').insert({ member_id: memberId, skill_id: skillId, level: newLevel })
}
// Historique
if (oldLevel !== newLevel) {
await supabase.from('skill_history').insert({
member_id: memberId,
skill_id: skillId,
old_level: oldLevel || null,
new_level: newLevel,
changed_by: profile.id,
})
}
load()
setEditing(null) setEditing(null)
toast.success('Niveau mis à jour') toast.success('Niveau mis à jour')
} }
@@ -86,109 +54,27 @@ export function SkillMatrix() {
<div className="space-y-6"> <div className="space-y-6">
<h1 className="text-2xl font-bold">Matrice des compétences</h1> <h1 className="text-2xl font-bold">Matrice des compétences</h1>
<div className="flex gap-4 flex-wrap"> <SkillMatrixFilters
<div className="flex items-center gap-2"> categories={categories}
<label className="text-sm text-gray-600">Catégorie :</label> members={members}
<select filterCat={filterCat}
className="border rounded px-2 py-1 text-sm" filterMember={filterMember}
value={filterCat} filterMinLevel={filterMinLevel}
onChange={(e) => setFilterCat(e.target.value)} onFilterChange={onFilterChange}
> />
<option value="all">Toutes</option>
{categories.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
))}
</select>
</div>
<div className="flex items-center gap-2">
<label className="text-sm text-gray-600">Membre :</label>
<select
className="border rounded px-2 py-1 text-sm"
value={filterMember}
onChange={(e) => setFilterMember(e.target.value)}
>
<option value="all">Tous</option>
{members.map((m) => (
<option key={m.id} value={m.id}>{m.full_name || m.email}</option>
))}
</select>
</div>
<div className="flex items-center gap-2">
<label className="text-sm text-gray-600">Niveau min. :</label>
<select
className="border rounded px-2 py-1 text-sm"
value={filterMinLevel}
onChange={(e) => setFilterMinLevel(Number(e.target.value))}
>
<option value={0}>Aucun</option>
{[1, 2, 3, 4].map((l) => <option key={l} value={l}>{l}</option>)}
</select>
</div>
</div>
<div className="overflow-auto"> <SkillMatrixTable
<table className="w-full border-collapse"> skills={filteredSkills}
<thead> members={visibleMembers}
<tr> levels={levels}
<th className="text-left p-2 bg-gray-100 border sticky left-0 z-10 min-w-[180px]">Membre</th> isAdmin={isAdmin}
{filteredSkills.map((s) => ( editing={editing}
<th key={s.id} className="p-2 bg-gray-100 border text-sm text-center min-w-[120px]">{s.name}</th> onEdit={setEditing}
))} onUpdate={handleUpdate}
</tr> onCancel={() => setEditing(null)}
</thead> />
<tbody>
{filteredByLevel.length === 0 && (
<tr><td colSpan={filteredSkills.length + 1} className="p-8 text-center text-gray-400">Aucun résultat</td></tr>
)}
{filteredByLevel.map((m) => (
<tr key={m.id} className="hover:bg-gray-50">
<td className="p-2 border font-medium sticky left-0 bg-white">
<span className="text-sm">{m.full_name || m.email}</span>
</td>
{filteredSkills.map((s) => {
const key = `${m.id}-${s.id}`
const level = levels[key]
const isEditing = editing === key
return (
<td key={s.id} className="p-2 border text-center">
{isEditing && isAdmin ? (
<SkillLevelSelect
value={level?.level || 1}
onChange={(v) => updateLevel(m.id, s.id, v)}
/>
) : (
level ? (
<SkillLevelBadge
level={level.level}
onClick={() => isAdmin && setEditing(key)}
/>
) : (
<span
className="text-gray-300 text-sm cursor-pointer"
onClick={() => isAdmin && setEditing(key)}
>
</span>
)
)}
{isEditing && isAdmin && (
<button
className="ml-1 text-xs text-red-500"
onClick={() => setEditing(null)}
>
</button>
)}
</td>
)
})}
</tr>
))}
</tbody>
</table>
</div>
<div className="flex gap-4 text-sm text-gray-500"> <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-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-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-amber-200 mr-1" /> Avancé</span>
+36 -57
View File
@@ -1,15 +1,18 @@
import { useEffect, useState } from 'react' import { useState } from 'react'
import { supabase } from '@/lib/supabase' import { supabase } from '@/lib/supabase'
import { useCategories } from '@/hooks/useCategories'
import { useSkills } from '@/hooks/useSkills'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { CategoryCard } from '@/components/skills/CategoryCard'
import { Search } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
export function Skills() { export function Skills() {
const [categories, setCategories] = useState([]) const { categories, refetch: refetchCats } = useCategories()
const [skills, setSkills] = useState([]) const { skills, refetch: refetchSkills } = useSkills()
const [search, setSearch] = useState('')
const [newCatName, setNewCatName] = useState('') const [newCatName, setNewCatName] = useState('')
const [newCatColor, setNewCatColor] = useState('#3b82f6') const [newCatColor, setNewCatColor] = useState('#3b82f6')
const [editCat, setEditCat] = useState(null) const [editCat, setEditCat] = useState(null)
@@ -17,17 +20,6 @@ export function Skills() {
const [catDialogOpen, setCatDialogOpen] = useState(false) const [catDialogOpen, setCatDialogOpen] = useState(false)
const [skillDialogOpen, setSkillDialogOpen] = useState(false) const [skillDialogOpen, setSkillDialogOpen] = useState(false)
useEffect(() => { load() }, [])
async function load() {
const [catRes, skillRes] = await Promise.all([
supabase.from('categories').select('*').order('name'),
supabase.from('skills').select('*, category:category_id(name)').order('name'),
])
if (catRes.data) setCategories(catRes.data)
if (skillRes.data) setSkills(skillRes.data)
}
async function saveCategory() { async function saveCategory() {
if (editCat) { if (editCat) {
await supabase.from('categories').update({ name: newCatName, color: newCatColor }).eq('id', editCat) await supabase.from('categories').update({ name: newCatName, color: newCatColor }).eq('id', editCat)
@@ -37,28 +29,29 @@ export function Skills() {
setCatDialogOpen(false) setCatDialogOpen(false)
setEditCat(null) setEditCat(null)
setNewCatName('') setNewCatName('')
load() refetchCats()
toast.success('Catégorie enregistrée') toast.success('Catégorie enregistrée')
} }
async function deleteCategory(id) { async function deleteCategory(id) {
const { error } = await supabase.from('categories').delete().eq('id', id) const { error } = await supabase.from('categories').delete().eq('id', id)
if (error) toast.error(error.message) if (error) toast.error(error.message)
else { load(); toast.success('Catégorie supprimée') } else { refetchCats(); toast.success('Catégorie supprimée') }
} }
async function saveSkill() { async function saveSkill() {
await supabase.from('skills').insert({ name: newSkill.name, category_id: newSkill.category_id }) await supabase.from('skills').insert({ name: newSkill.name, category_id: newSkill.category_id })
setSkillDialogOpen(false) setSkillDialogOpen(false)
setNewSkill({ name: '', category_id: '' }) setNewSkill({ name: '', category_id: '' })
load() refetchSkills()
toast.success('Compétence ajoutée') toast.success('Compétence ajoutée')
} }
async function deleteSkill(id) { function openEditCategory(cat) {
await supabase.from('skills').delete().eq('id', id) setEditCat(cat.id)
load() setNewCatName(cat.name)
toast.success('Compétence supprimée') setNewCatColor(cat.color)
setCatDialogOpen(true)
} }
return ( return (
@@ -73,7 +66,7 @@ export function Skills() {
<div className="space-y-4"> <div className="space-y-4">
<Input placeholder="Nom" value={newSkill.name} onChange={(e) => setNewSkill({ ...newSkill, name: e.target.value })} /> <Input placeholder="Nom" value={newSkill.name} onChange={(e) => setNewSkill({ ...newSkill, name: e.target.value })} />
<select <select
className="w-full border rounded-md px-3 py-2" className="w-full border rounded-md px-3 py-2 dark:bg-gray-800 dark:border-gray-700"
value={newSkill.category_id} value={newSkill.category_id}
onChange={(e) => setNewSkill({ ...newSkill, category_id: e.target.value })} onChange={(e) => setNewSkill({ ...newSkill, category_id: e.target.value })}
> >
@@ -99,41 +92,27 @@ export function Skills() {
</div> </div>
</div> </div>
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
className="pl-10"
placeholder="Rechercher une compétence..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
{categories.map((cat) => { {categories.map((cat) => {
const catSkills = skills.filter((s) => s.category_id === cat.id) const catSkills = skills.filter((s) => s.category_id === cat.id && (!search || s.name.toLowerCase().includes(search.toLowerCase())))
if (search && catSkills.length === 0) return null
return ( return (
<Card key={cat.id}> <CategoryCard
<CardHeader className="flex flex-row items-center justify-between py-3"> key={cat.id}
<CardTitle className="text-lg flex items-center gap-2"> category={cat}
<span className="w-3 h-3 rounded-full" style={{ backgroundColor: cat.color }} /> skills={catSkills}
{cat.name} onEdit={openEditCategory}
<Badge variant="secondary" className="ml-2">{catSkills.length}</Badge> onDelete={deleteCategory}
</CardTitle> />
<div className="flex gap-1">
<Button size="sm" variant="ghost" onClick={() => {
setEditCat(cat.id)
setNewCatName(cat.name)
setNewCatColor(cat.color)
setCatDialogOpen(true)
}}></Button>
<Button size="sm" variant="ghost" onClick={() => deleteCategory(cat.id)}>🗑</Button>
</div>
</CardHeader>
<CardContent>
{catSkills.length === 0 ? (
<p className="text-sm text-gray-400">Aucune compétence dans cette catégorie</p>
) : (
<div className="flex flex-wrap gap-2">
{catSkills.map((s) => (
<Badge key={s.id} variant="outline" className="pr-1">
{s.name}
<button className="ml-1 text-gray-400 hover:text-red-500" onClick={() => deleteSkill(s.id)}>×</button>
</Badge>
))}
</div>
)}
</CardContent>
</Card>
) )
})} })}
</div> </div>