From 2ee7decbfddec2be92bbc4f83415c4bae55d106f Mon Sep 17 00:00:00 2001 From: TopheC Date: Sun, 24 May 2026 20:30:45 +0200 Subject: [PATCH] Remaster avec skill supabase only et grillme --- .env.example | 6 +- AGENTS.md | 35 ++- eslint.config.js | 11 + package-lock.json | 365 ++++++++++++++++++++++++++++- package.json | 11 +- scripts/seed.mjs | 25 +- skills-lock.json | 6 + src/App.jsx | 69 +++--- src/components/Layout.jsx | 123 ++++++---- src/components/SkillLevelBadge.jsx | 1 + src/context/AuthContext.jsx | 1 + src/main.jsx | 5 +- src/pages/AcceptInvite.jsx | 24 +- src/pages/Dashboard.jsx | 11 +- src/pages/History.jsx | 81 +++---- src/pages/Members.jsx | 26 +- src/pages/Profile.jsx | 6 +- src/pages/SkillMatrix.jsx | 204 ++++------------ src/pages/Skills.jsx | 93 +++----- 19 files changed, 734 insertions(+), 369 deletions(-) diff --git a/.env.example b/.env.example index 29b4516..3c3a6d4 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/AGENTS.md b/AGENTS.md index 8cbd0bc..05c8b78 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 `` (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 ``; admin-only routes (`/members`, `/skills`) also wrapped in `` +- **Routing**: `src/App.jsx` — public routes `/login`, `/register`, `/accept-invite`; protected routes wrapped in ``; admin-only routes (`/members`, `/skills`) also wrapped in `` + `` (React.lazy code splitting) +- **Error handling**: `` 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 diff --git a/eslint.config.js b/eslint.config.js index ea36dd3..fd0009d 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -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' }, + }, ]) diff --git a/package-lock.json b/package-lock.json index 2c2ba00..2c7e7e8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 4d5331e..d739861 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/scripts/seed.mjs b/scripts/seed.mjs index 550635b..8728b02 100644 --- a/scripts/seed.mjs +++ b/scripts/seed.mjs @@ -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, diff --git a/skills-lock.json b/skills-lock.json index 43058c0..f2f84b5 100644 --- a/skills-lock.json +++ b/skills-lock.json @@ -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", diff --git a/src/App.jsx b/src/App.jsx index 1ebefd6..ece44f2 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -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 {children} +} + +function SuspenseWrapper({ children }) { return ( - {children} + Chargement...}> + {children} + ) } @@ -23,32 +32,38 @@ export default function App() { return ( - - - } /> - } /> - } /> + + + + } /> + } /> + } /> - - } /> - - } /> - - } /> - - } /> + + } /> + + } /> + + } /> + + } /> - - } /> - - } /> - + + + + } /> + + + + } /> + + ) diff --git a/src/components/Layout.jsx b/src/components/Layout.jsx index 8ffff1d..6f142a9 100644 --- a/src/components/Layout.jsx +++ b/src/components/Layout.jsx @@ -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 ( -
- -
- {children}
) diff --git a/src/components/SkillLevelBadge.jsx b/src/components/SkillLevelBadge.jsx index 4489cbc..b5f2672 100644 --- a/src/components/SkillLevelBadge.jsx +++ b/src/components/SkillLevelBadge.jsx @@ -1,3 +1,4 @@ +/* eslint-disable react-refresh/only-export-components */ import { Badge } from '@/components/ui/badge' const levelConfig = { diff --git a/src/context/AuthContext.jsx b/src/context/AuthContext.jsx index 3e3fef7..1e815bb 100644 --- a/src/context/AuthContext.jsx +++ b/src/context/AuthContext.jsx @@ -1,3 +1,4 @@ +/* eslint-disable react-refresh/only-export-components */ import { createContext, useContext, useEffect, useState } from 'react' import { supabase } from '@/lib/supabase' diff --git a/src/main.jsx b/src/main.jsx index b9a1a6d..da67b0a 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -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( - + + + , ) diff --git a/src/pages/AcceptInvite.jsx b/src/pages/AcceptInvite.jsx index e7aa9dd..93ee289 100644 --- a/src/pages/AcceptInvite.jsx +++ b/src/pages/AcceptInvite.jsx @@ -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 ( -
+
Accepter l'invitation -

{email}

+

{email}

diff --git a/src/pages/Dashboard.jsx b/src/pages/Dashboard.jsx index 0f4f5d8..485bdea 100644 --- a/src/pages/Dashboard.jsx +++ b/src/pages/Dashboard.jsx @@ -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() {

Tableau de bord

-
+
Membres

{stats.members}

@@ -69,12 +68,12 @@ export function Dashboard() {
-
+
Compétences les mieux notées {topSkills.length === 0 ? ( -

Aucune évaluation pour le moment

+

Aucune évaluation pour le moment

) : (
    {topSkills.map((s) => ( @@ -92,11 +91,11 @@ export function Dashboard() { Dernières évolutions {recentChanges.length === 0 ? ( -

    Aucun changement récent

    +

    Aucun changement récent

    ) : (
      {recentChanges.map((c) => ( -
    • +
    • {c.member?.full_name} {' '}a mis à jour{' '} {c.skill?.name} diff --git a/src/pages/History.jsx b/src/pages/History.jsx index 450345c..2cff147 100644 --- a/src/pages/History.jsx +++ b/src/pages/History.jsx @@ -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 (
      @@ -46,9 +17,9 @@ export function History() {
      setName(e.target.value)} />
      diff --git a/src/pages/SkillMatrix.jsx b/src/pages/SkillMatrix.jsx index b76d72b..dc6943b 100644 --- a/src/pages/SkillMatrix.jsx +++ b/src/pages/SkillMatrix.jsx @@ -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() {

      Matrice des compétences

      -
      -
      - - -
      -
      - - -
      -
      - - -
      -
      + -
      - - - - - {filteredSkills.map((s) => ( - - ))} - - - - {filteredByLevel.length === 0 && ( - - )} - {filteredByLevel.map((m) => ( - - - {filteredSkills.map((s) => { - const key = `${m.id}-${s.id}` - const level = levels[key] - const isEditing = editing === key - return ( - - ) - })} - - ))} - -
      Membre{s.name}
      Aucun résultat
      - {m.full_name || m.email} - - {isEditing && isAdmin ? ( - updateLevel(m.id, s.id, v)} - /> - ) : ( - level ? ( - isAdmin && setEditing(key)} - /> - ) : ( - isAdmin && setEditing(key)} - > - — - - ) - )} - {isEditing && isAdmin && ( - - )} -
      -
      + setEditing(null)} + /> -
      +
      Débutant Intermédiaire Avancé diff --git a/src/pages/Skills.jsx b/src/pages/Skills.jsx index 3762330..e09c5fb 100644 --- a/src/pages/Skills.jsx +++ b/src/pages/Skills.jsx @@ -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() {
      setNewSkill({ ...newSkill, name: e.target.value })} /> setSearch(e.target.value)} + /> +
      + {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 ( - - - - - {cat.name} - {catSkills.length} - -
      - - -
      -
      - - {catSkills.length === 0 ? ( -

      Aucune compétence dans cette catégorie

      - ) : ( -
      - {catSkills.map((s) => ( - - {s.name} - - - ))} -
      - )} -
      -
      + ) })}