From 6f00da6d10ed7f33a25323592f22ed839fd1daec Mon Sep 17 00:00:00 2001 From: mrKamoo Date: Wed, 22 Apr 2026 15:26:15 +0200 Subject: [PATCH] Enhance candidates management: add city field, drag-and-drop ranking persistence, AI analysis popover, and CV preview --- .env.example | 68 ---- app/Http/Controllers/CandidateController.php | 35 +- app/Models/Candidate.php | 2 +- ...459_add_sort_order_to_candidates_table.php | 28 ++ ...22_112940_add_city_to_candidates_table.php | 28 ++ package-lock.json | 41 +-- package.json | 3 +- resources/js/Components/Dropdown.vue | 2 + resources/js/Layouts/AdminLayout.vue | 44 ++- resources/js/Pages/Admin/Candidates/Index.vue | 26 +- .../js/Pages/Admin/Candidates/Selected.vue | 302 ++++++++++++++++++ resources/js/Pages/Admin/Candidates/Show.vue | 15 +- routes/web.php | 2 + 13 files changed, 480 insertions(+), 116 deletions(-) delete mode 100644 .env.example create mode 100644 database/migrations/2026_04_22_110459_add_sort_order_to_candidates_table.php create mode 100644 database/migrations/2026_04_22_112940_add_city_to_candidates_table.php create mode 100644 resources/js/Pages/Admin/Candidates/Selected.vue diff --git a/.env.example b/.env.example deleted file mode 100644 index 776f182..0000000 --- a/.env.example +++ /dev/null @@ -1,68 +0,0 @@ -APP_NAME=Recru.IT -# PRODUCTION: Set to 'production' and set APP_DEBUG=false -APP_ENV=local -APP_KEY= -APP_DEBUG=false -APP_URL=http://localhost - -APP_LOCALE=en -APP_FALLBACK_LOCALE=en -APP_FAKER_LOCALE=en_US - -APP_MAINTENANCE_DRIVER=file -# APP_MAINTENANCE_STORE=database - -# PHP_CLI_SERVER_WORKERS=4 - -BCRYPT_ROUNDS=12 - -LOG_CHANNEL=stack -LOG_STACK=single -LOG_DEPRECATIONS_CHANNEL=null -# PRODUCTION: Use 'error' to avoid exposing sensitive data in logs -LOG_LEVEL=error - -DB_CONNECTION=sqlite -# DB_HOST=127.0.0.1 -# DB_PORT=3306 -# DB_DATABASE=laravel -# DB_USERNAME=root -# DB_PASSWORD= - -SESSION_DRIVER=database -SESSION_LIFETIME=120 -# SECURITY: Must be 'true' in production to encrypt session data at rest -SESSION_ENCRYPT=true -SESSION_PATH=/ -SESSION_DOMAIN=null - -BROADCAST_CONNECTION=log -FILESYSTEM_DISK=local -QUEUE_CONNECTION=database - -CACHE_STORE=database -# CACHE_PREFIX= - -MEMCACHED_HOST=127.0.0.1 - -REDIS_CLIENT=phpredis -REDIS_HOST=127.0.0.1 -REDIS_PASSWORD=null -REDIS_PORT=6379 - -MAIL_MAILER=log -MAIL_SCHEME=null -MAIL_HOST=127.0.0.1 -MAIL_PORT=2525 -MAIL_USERNAME=null -MAIL_PASSWORD=null -MAIL_FROM_ADDRESS="hello@example.com" -MAIL_FROM_NAME="${APP_NAME}" - -AWS_ACCESS_KEY_ID= -AWS_SECRET_ACCESS_KEY= -AWS_DEFAULT_REGION=us-east-1 -AWS_BUCKET= -AWS_USE_PATH_STYLE_ENDPOINT=false - -VITE_APP_NAME="${APP_NAME}" diff --git a/app/Http/Controllers/CandidateController.php b/app/Http/Controllers/CandidateController.php index ecd5331..d92ef69 100644 --- a/app/Http/Controllers/CandidateController.php +++ b/app/Http/Controllers/CandidateController.php @@ -39,6 +39,36 @@ class CandidateController extends Controller ]); } + public function selectedCandidates() + { + $candidates = Candidate::with(['user', 'jobPosition', 'attempts.quiz', 'documents']) + ->where('is_selected', true) + ->orderBy('sort_order') + ->get() + ->map(function($candidate) { + $candidate->weighted_score = $candidate->weighted_score; + return $candidate; + }); + + return \Inertia\Inertia::render('Admin/Candidates/Selected', [ + 'candidates' => $candidates + ]); + } + + public function updateOrder(Request $request) + { + $request->validate([ + 'ids' => 'required|array', + 'ids.*' => 'exists:candidates,id', + ]); + + foreach ($request->ids as $index => $id) { + Candidate::where('id', $id)->update(['sort_order' => $index]); + } + + return back()->with('success', 'Classement enregistré.'); + } + public function store(Request $request) { $request->validate([ @@ -46,6 +76,7 @@ class CandidateController extends Controller 'email' => 'required|string|email|max:255|unique:users', 'phone' => 'nullable|string|max:20', 'linkedin_url' => 'nullable|url|max:255', + 'city' => 'nullable|string|max:255', 'cv' => 'nullable|mimes:pdf|max:5120', 'cover_letter' => 'nullable|mimes:pdf|max:5120', 'tenant_id' => 'nullable|exists:tenants,id', @@ -65,6 +96,7 @@ class CandidateController extends Controller $candidate = $user->candidate()->create([ 'phone' => $request->phone, 'linkedin_url' => $request->linkedin_url, + 'city' => $request->city, 'status' => 'en_attente', 'tenant_id' => auth()->user()->isSuperAdmin() ? $request->tenant_id : auth()->user()->tenant_id, 'job_position_id' => $request->job_position_id, @@ -143,6 +175,7 @@ class CandidateController extends Controller 'email' => 'nullable|string|email|max:255|unique:users,email,' . $candidate->user_id, 'phone' => 'nullable|string|max:255', 'linkedin_url' => 'nullable|url|max:255', + 'city' => 'nullable|string|max:255', ]); // Update User info if name or email present @@ -151,7 +184,7 @@ class CandidateController extends Controller } // Update Candidate info - $candidate->update($request->only(['phone', 'linkedin_url'])); + $candidate->update($request->only(['phone', 'linkedin_url', 'city'])); if ($request->hasFile('cv')) { $this->replaceDocument($candidate, $request->file('cv'), 'cv'); diff --git a/app/Models/Candidate.php b/app/Models/Candidate.php index 1d95730..d1971f0 100644 --- a/app/Models/Candidate.php +++ b/app/Models/Candidate.php @@ -11,7 +11,7 @@ use Illuminate\Database\Eloquent\Factories\HasFactory; use App\Traits\BelongsToTenant; -#[Fillable(['user_id', 'job_position_id', 'phone', 'linkedin_url', 'status', 'is_selected', 'notes', 'cv_score', 'motivation_score', 'interview_score', 'interview_details', 'ai_analysis', 'tenant_id'])] +#[Fillable(['user_id', 'job_position_id', 'phone', 'linkedin_url', 'city', 'status', 'is_selected', 'sort_order', 'notes', 'cv_score', 'motivation_score', 'interview_score', 'interview_details', 'ai_analysis', 'tenant_id'])] class Candidate extends Model { use HasFactory, BelongsToTenant; diff --git a/database/migrations/2026_04_22_110459_add_sort_order_to_candidates_table.php b/database/migrations/2026_04_22_110459_add_sort_order_to_candidates_table.php new file mode 100644 index 0000000..84f5959 --- /dev/null +++ b/database/migrations/2026_04_22_110459_add_sort_order_to_candidates_table.php @@ -0,0 +1,28 @@ +integer('sort_order')->default(0)->after('is_selected'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('candidates', function (Blueprint $table) { + $table->dropColumn('sort_order'); + }); + } +}; diff --git a/database/migrations/2026_04_22_112940_add_city_to_candidates_table.php b/database/migrations/2026_04_22_112940_add_city_to_candidates_table.php new file mode 100644 index 0000000..1135de3 --- /dev/null +++ b/database/migrations/2026_04_22_112940_add_city_to_candidates_table.php @@ -0,0 +1,28 @@ +string('city')->nullable()->after('linkedin_url'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('candidates', function (Blueprint $table) { + $table->dropColumn('city'); + }); + } +}; diff --git a/package-lock.json b/package-lock.json index 3164d1a..4788559 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "QuizzCabm", + "name": "RECRU_IT_V2", "lockfileVersion": 3, "requires": true, "packages": { @@ -8,7 +8,8 @@ "@tailwindcss/typography": "^0.5.19", "chart.js": "^4.5.1", "date-fns": "^4.1.0", - "marked": "^17.0.4" + "marked": "^17.0.4", + "vuedraggable": "^4.1.0" }, "devDependencies": { "@inertiajs/vue3": "^2.0.0", @@ -41,7 +42,6 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -51,7 +51,6 @@ "version": "7.28.5", "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -61,7 +60,6 @@ "version": "7.29.2", "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", - "dev": true, "license": "MIT", "dependencies": { "@babel/types": "^7.29.0" @@ -77,7 +75,6 @@ "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", @@ -900,7 +897,6 @@ "version": "3.5.30", "resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.30.tgz", "integrity": "sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw==", - "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.29.0", @@ -914,7 +910,6 @@ "version": "3.5.30", "resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.30.tgz", "integrity": "sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g==", - "dev": true, "license": "MIT", "dependencies": { "@vue/compiler-core": "3.5.30", @@ -925,7 +920,6 @@ "version": "3.5.30", "resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.30.tgz", "integrity": "sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A==", - "dev": true, "license": "MIT", "dependencies": { "@babel/parser": "^7.29.0", @@ -943,7 +937,6 @@ "version": "3.5.30", "resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.30.tgz", "integrity": "sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA==", - "dev": true, "license": "MIT", "dependencies": { "@vue/compiler-dom": "3.5.30", @@ -954,7 +947,6 @@ "version": "3.5.30", "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.30.tgz", "integrity": "sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q==", - "dev": true, "license": "MIT", "dependencies": { "@vue/shared": "3.5.30" @@ -964,7 +956,6 @@ "version": "3.5.30", "resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.30.tgz", "integrity": "sha512-e0Z+8PQsUTdwV8TtEsLzUM7SzC7lQwYKePydb7K2ZnmS6jjND+WJXkmmfh/swYzRyfP1EY3fpdesyYoymCzYfg==", - "dev": true, "license": "MIT", "dependencies": { "@vue/reactivity": "3.5.30", @@ -975,7 +966,6 @@ "version": "3.5.30", "resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.30.tgz", "integrity": "sha512-2UIGakjU4WSQ0T4iwDEW0W7vQj6n7AFn7taqZ9Cvm0Q/RA2FFOziLESrDL4GmtI1wV3jXg5nMoJSYO66egDUBw==", - "dev": true, "license": "MIT", "dependencies": { "@vue/reactivity": "3.5.30", @@ -988,7 +978,6 @@ "version": "3.5.30", "resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.30.tgz", "integrity": "sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ==", - "dev": true, "license": "MIT", "dependencies": { "@vue/compiler-ssr": "3.5.30", @@ -1002,7 +991,6 @@ "version": "3.5.30", "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.30.tgz", "integrity": "sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ==", - "dev": true, "license": "MIT" }, "node_modules/ansi-regex": { @@ -1420,7 +1408,6 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, "license": "MIT" }, "node_modules/date-fns": { @@ -1512,7 +1499,6 @@ "version": "7.0.1", "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.12" @@ -1584,7 +1570,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "dev": true, "license": "MIT" }, "node_modules/fast-glob": { @@ -2242,7 +2227,6 @@ "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" @@ -2866,6 +2850,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/sortablejs": { + "version": "1.14.0", + "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.14.0.tgz", + "integrity": "sha512-pBXvQCs5/33fdN1/39pPL0NZF20LeRbLQ5jtnheIPN9JQAaufGjKdWduZn4U7wCtVuzKhmRkI0DFYHYRbB2H1w==", + "license": "MIT" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -3257,7 +3247,6 @@ "version": "3.5.30", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.30.tgz", "integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==", - "dev": true, "license": "MIT", "dependencies": { "@vue/compiler-dom": "3.5.30", @@ -3275,6 +3264,18 @@ } } }, + "node_modules/vuedraggable": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/vuedraggable/-/vuedraggable-4.1.0.tgz", + "integrity": "sha512-FU5HCWBmsf20GpP3eudURW3WdWTKIbEIQxh9/8GE806hydR9qZqRRxRE3RjqX7PkuLuMQG/A7n3cfj9rCEchww==", + "license": "MIT", + "dependencies": { + "sortablejs": "1.14.0" + }, + "peerDependencies": { + "vue": "^3.0.1" + } + }, "node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", diff --git a/package.json b/package.json index 42d4099..8da98a9 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "@tailwindcss/typography": "^0.5.19", "chart.js": "^4.5.1", "date-fns": "^4.1.0", - "marked": "^17.0.4" + "marked": "^17.0.4", + "vuedraggable": "^4.1.0" } } diff --git a/resources/js/Components/Dropdown.vue b/resources/js/Components/Dropdown.vue index ede3626..0f4b441 100644 --- a/resources/js/Components/Dropdown.vue +++ b/resources/js/Components/Dropdown.vue @@ -36,6 +36,8 @@ const alignmentClasses = computed(() => { return 'ltr:origin-top-left rtl:origin-top-right start-0'; } else if (props.align === 'right') { return 'ltr:origin-top-right rtl:origin-top-left end-0'; + } else if (props.align === 'top-right') { + return 'origin-bottom-right bottom-full mb-2 end-0 !mt-0'; } else { return 'origin-top'; } diff --git a/resources/js/Layouts/AdminLayout.vue b/resources/js/Layouts/AdminLayout.vue index aae526b..ff4235f 100644 --- a/resources/js/Layouts/AdminLayout.vue +++ b/resources/js/Layouts/AdminLayout.vue @@ -29,7 +29,7 @@ const navItems = [ }, { route: 'admin.candidates.index', - match: 'admin.candidates.*', + match: ['admin.candidates.index', 'admin.candidates.show'], label: 'Candidats', icon: 'M17 21v-2a4 4 0 00-4-4H5a4 4 0 00-4 4v2 M9 11a4 4 0 100-8 4 4 0 000 8z M23 21v-2a4 4 0 00-3-3.87 M16 3.13a4 4 0 010 7.75', }, @@ -50,6 +50,11 @@ const navItems = [ label: 'Comparateur', icon: 'M18 20V10 M12 20V4 M6 20v-6', }, + { + route: 'admin.candidates.selected', + label: 'Selection', + icon: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z', + }, ]; const superAdminItems = [ @@ -74,8 +79,13 @@ const superAdminItems = [ ]; const isActive = (item) => { - if (item.match) return route().current(item.match); - return route().current(item.route); + if (!item.match) return route().current(item.route); + + if (Array.isArray(item.match)) { + return item.match.some(m => route().current(m)); + } + + return route().current(item.match); }; @@ -165,20 +175,22 @@ const isActive = (item) => {
-
-
- {{ $page.props.auth.user.name.charAt(0) }} -
-
-
{{ $page.props.auth.user.name }}
-
{{ $page.props.auth.user.role }}
-
- +
+