Enhance candidates management: add city field, drag-and-drop ranking persistence, AI analysis popover, and CV preview

This commit is contained in:
mrKamoo
2026-04-22 15:26:15 +02:00
parent 174f229b5d
commit 6f00da6d10
13 changed files with 480 additions and 116 deletions

View File

@@ -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}"

View File

@@ -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');

View File

@@ -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;

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('candidates', function (Blueprint $table) {
$table->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');
});
}
};

View File

@@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('candidates', function (Blueprint $table) {
$table->string('city')->nullable()->after('linkedin_url');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('candidates', function (Blueprint $table) {
$table->dropColumn('city');
});
}
};

41
package-lock.json generated
View File

@@ -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",

View File

@@ -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"
}
}

View File

@@ -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';
}

View File

@@ -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);
};
</script>
@@ -165,20 +175,22 @@ const isActive = (item) => {
<!-- Footer sidebar : user + collapse -->
<div class="px-3 py-3 border-t border-white/[0.07] shrink-0">
<!-- User info (sidebar ouverte) -->
<div v-if="isSidebarOpen" class="flex items-center gap-2.5 mb-3">
<div class="w-8 h-8 rounded-full bg-highlight flex items-center justify-center text-[12px] font-black text-highlight-dark shrink-0">
{{ $page.props.auth.user.name.charAt(0) }}
</div>
<div class="overflow-hidden flex-1 min-w-0">
<div class="text-[12px] font-bold text-white truncate">{{ $page.props.auth.user.name }}</div>
<div class="text-[10px] text-white/40 truncate">{{ $page.props.auth.user.role }}</div>
</div>
<Dropdown align="right" width="48">
<div v-if="isSidebarOpen" class="mb-3 w-full">
<Dropdown align="top-right" width="48">
<template #trigger>
<button class="p-1.5 rounded-lg text-white/40 hover:text-white hover:bg-white/10 transition-colors">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/>
</svg>
<button class="w-full flex items-center gap-2.5 text-left p-1.5 -ml-1.5 rounded-xl hover:bg-white/5 transition-colors">
<div class="w-8 h-8 rounded-full bg-highlight flex items-center justify-center text-[12px] font-black text-highlight-dark shrink-0">
{{ $page.props.auth.user.name.charAt(0) }}
</div>
<div class="overflow-hidden flex-1 min-w-0">
<div class="text-[12px] font-bold text-white truncate">{{ $page.props.auth.user.name }}</div>
<div class="text-[10px] text-white/40 truncate">{{ $page.props.auth.user.role }}</div>
</div>
<div class="text-white/40">
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="1"/><circle cx="19" cy="12" r="1"/><circle cx="5" cy="12" r="1"/>
</svg>
</div>
</button>
</template>
<template #content>

View File

@@ -26,7 +26,8 @@ const form = useForm({
name: '',
email: '',
phone: '',
linkedin_url: '',
linkedin_url: '',
city: '',
cv: null,
cover_letter: null,
tenant_id: '',
@@ -272,6 +273,12 @@ const batchAnalyze = async () => {
<svg v-show="sortKey === 'tenant.name'" xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" :class="{ 'rotate-180': sortOrder === -1 }" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" /></svg>
</div>
</th>
<th @click="sortBy('city')" class="px-8 py-5 text-[10px] font-subtitle font-black uppercase tracking-[0.2em] text-anthracite/40 cursor-pointer hover:text-primary transition-colors">
<div class="flex items-center gap-2">
Ville
<svg v-show="sortKey === 'city'" xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" :class="{ 'rotate-180': sortOrder === -1 }" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M5.293 7.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z" clip-rule="evenodd" /></svg>
</div>
</th>
<th @click="sortBy('job_position.title')" class="px-8 py-5 text-[10px] font-subtitle font-black uppercase tracking-[0.2em] text-anthracite/40 cursor-pointer hover:text-primary transition-colors">
<div class="flex items-center gap-2">
Poste Ciblé
@@ -332,6 +339,9 @@ const batchAnalyze = async () => {
<td class="px-8 py-5 text-[10px] font-black uppercase tracking-widest text-primary/60" v-if="$page.props.auth.user.role === 'super_admin'">
{{ candidate.tenant ? candidate.tenant.name : 'Aucune' }}
</td>
<td class="px-8 py-5 text-[10px] font-black uppercase tracking-widest text-anthracite/60">
{{ candidate.city || '--' }}
</td>
<td class="px-8 py-5 text-xs font-bold text-anthracite">
{{ candidate.job_position ? candidate.job_position.title : 'Non assigné' }}
</td>
@@ -358,8 +368,8 @@ const batchAnalyze = async () => {
<div
class="px-2 py-0.5 rounded-lg text-[10px] font-black shadow-sm"
:class="[
candidate.ai_analysis.match_score >= 80 ? 'bg-emerald-50 text-emerald-700 border border-emerald-200' :
candidate.ai_analysis.match_score >= 60 ? 'bg-highlight/10 text-[#3a2800] border border-highlight/30' :
candidate.ai_analysis.match_score >= 90 ? 'bg-emerald-50 text-emerald-700 border border-emerald-200' :
candidate.ai_analysis.match_score >= 80 ? 'bg-highlight/10 text-[#3a2800] border border-highlight/30' :
'bg-accent/10 text-accent border border-accent/20'
]"
>
@@ -396,7 +406,7 @@ const batchAnalyze = async () => {
</td>
</tr>
<tr v-if="candidates.length === 0">
<td colspan="11" class="px-8 py-16 text-center">
<td colspan="12" class="px-8 py-16 text-center">
<div class="text-anthracite/40 italic font-medium font-subtitle">
Aucun candidat trouvé.
</div>
@@ -432,6 +442,14 @@ const batchAnalyze = async () => {
<TextInput id="phone" type="text" class="mt-1 block w-full" v-model="form.phone" />
<InputError class="mt-2" :message="form.errors.phone" />
</div>
<div>
<InputLabel for="city" value="Ville" />
<TextInput id="city" type="text" class="mt-1 block w-full" v-model="form.city" />
<InputError class="mt-2" :message="form.errors.city" />
</div>
</div>
<div class="grid grid-cols-1 gap-4">
<div>
<InputLabel for="linkedin_url" value="LinkedIn URL" />
<TextInput id="linkedin_url" type="url" class="mt-1 block w-full" v-model="form.linkedin_url" />

View File

@@ -0,0 +1,302 @@
<script setup>
import AdminLayout from '@/Layouts/AdminLayout.vue';
import { Head, Link, router } from '@inertiajs/vue3';
import { ref, computed, watch } from 'vue';
import draggable from 'vuedraggable';
import Modal from '@/Components/Modal.vue';
import SecondaryButton from '@/Components/SecondaryButton.vue';
const props = defineProps({
candidates: Array
});
const searchQuery = ref('');
// Maintain local state for dragging
const localCandidates = ref([]);
// We initialize sorting based on the server data
watch(() => props.candidates, (newVal) => {
localCandidates.value = [...newVal];
}, { immediate: true });
const filteredCandidates = computed({
get() {
const query = searchQuery.value.toLowerCase();
if (!query) return localCandidates.value;
return localCandidates.value.filter(c =>
c.user.name.toLowerCase().includes(query) ||
c.user.email.toLowerCase().includes(query) ||
(c.job_position && c.job_position.title.toLowerCase().includes(query))
);
},
set(val) {
if (!searchQuery.value) {
localCandidates.value = val;
saveOrder();
}
}
});
const saveOrder = () => {
router.post(route('admin.candidates.update-order'), {
ids: localCandidates.value.map(c => c.id)
}, {
preserveScroll: true,
onSuccess: () => {
// Optional: Show a toast or notification
}
});
};
const hoveredCandidateId = ref(null);
const popoverPosition = ref({ top: 0, left: 0 });
const handleMouseEnter = (event, id) => {
hoveredCandidateId.value = id;
const rect = event.currentTarget.getBoundingClientRect();
popoverPosition.value = {
top: rect.bottom + window.scrollY + 10,
left: rect.left + window.scrollX
};
};
const handleMouseLeave = () => {
hoveredCandidateId.value = null;
};
const selectedDocument = ref(null);
const openCvPreview = (candidate) => {
const cv = (candidate.documents || []).find(d => d.type === 'cv');
if (cv) {
selectedDocument.value = cv;
} else {
alert("Aucun CV n'a été trouvé pour ce candidat.");
}
};
</script>
<template>
<Head title="Candidats Sélectionnés" />
<AdminLayout>
<template #header>
Candidats Sélectionnés (Comparateur)
</template>
<div class="mb-8 flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
<div>
<h3 class="text-2xl font-bold">Listes de Candidats retenus</h3>
<p class="text-sm text-slate-500 mt-1">
Faites glisser les candidats sur la poignée () pour modifier manuellement le classement.
</p>
</div>
<div class="relative w-full sm:w-64">
<span class="absolute inset-y-0 left-0 pl-3 flex items-center text-slate-400">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</span>
<input
v-model="searchQuery"
type="text"
placeholder="Rechercher..."
class="block w-full pl-10 pr-3 py-2 border border-slate-300 dark:border-slate-700 rounded-lg leading-5 bg-white dark:bg-slate-800 placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm transition-all"
>
</div>
</div>
<div class="bg-white dark:bg-slate-800 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-700 overflow-hidden text-sm">
<table class="w-full text-left">
<thead class="bg-slate-50 dark:bg-slate-700/50 border-b border-slate-200 dark:border-slate-700">
<tr>
<th class="px-4 py-3 font-bold text-slate-700 dark:text-slate-300 uppercase tracking-wider text-xs">Ordre</th>
<th class="px-4 py-3 font-bold text-slate-700 dark:text-slate-300 uppercase tracking-wider text-xs">Candidat</th>
<th class="px-4 py-3 font-bold text-slate-700 dark:text-slate-300 uppercase tracking-wider text-xs">Poste</th>
<th class="px-4 py-3 font-bold text-slate-700 dark:text-slate-300 uppercase tracking-wider text-xs text-center">Ville</th>
<th class="px-4 py-3 font-bold text-slate-700 dark:text-slate-300 uppercase tracking-wider text-xs text-center">Score Global (/20)</th>
<th class="px-4 py-3 font-bold text-slate-700 dark:text-slate-300 uppercase tracking-wider text-xs text-center">IA Match</th>
<th class="px-4 py-3 font-bold text-slate-700 dark:text-slate-300 uppercase tracking-wider text-xs text-center">CV (/20)</th>
<th class="px-4 py-3 font-bold text-slate-700 dark:text-slate-300 uppercase tracking-wider text-xs text-center">Motiv (/10)</th>
<th class="px-4 py-3 font-bold text-slate-700 dark:text-slate-300 uppercase tracking-wider text-xs text-center">Test QCM (/20)</th>
<th class="px-4 py-3 font-bold text-slate-700 dark:text-slate-300 uppercase tracking-wider text-xs text-center">Entretien (/30)</th>
</tr>
</thead>
<draggable
v-model="filteredCandidates"
tag="tbody"
item-key="id"
class="divide-y divide-slate-200 dark:divide-slate-700"
handle=".drag-handle"
:animation="200"
:disabled="searchQuery.length > 0"
>
<template #item="{ element: candidate, index }">
<tr class="hover:bg-slate-50/50 dark:hover:bg-slate-700/30 transition-colors group bg-white dark:bg-slate-800">
<td class="px-4 py-3">
<div class="flex items-center gap-3">
<div
class="drag-handle text-slate-400 transition-colors"
:class="searchQuery.length > 0 ? 'opacity-30 cursor-not-allowed' : 'cursor-grab active:cursor-grabbing hover:text-slate-600'"
:title="searchQuery.length > 0 ? 'Désactivé pendant la recherche' : 'Glisser pour réordonner'"
>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="9" cy="12" r="1"/><circle cx="9" cy="5" r="1"/><circle cx="9" cy="19" r="1"/>
<circle cx="15" cy="12" r="1"/><circle cx="15" cy="5" r="1"/><circle cx="15" cy="19" r="1"/>
</svg>
</div>
<div
class="w-8 h-8 rounded-full flex items-center justify-center font-bold text-sm"
:class="[
index === 0 && !searchQuery ? 'bg-yellow-100 text-yellow-700 shadow-sm' :
index === 1 && !searchQuery ? 'bg-slate-200 text-slate-700 shadow-sm' :
index === 2 && !searchQuery ? 'bg-orange-100 text-orange-700 shadow-sm' : 'bg-slate-50 text-slate-500'
]"
>
{{ index + 1 }}
</div>
</div>
</td>
<td class="px-4 py-3 relative">
<div class="flex items-center gap-2">
<div
class="font-bold text-slate-900 dark:text-slate-100"
@mouseenter="handleMouseEnter($event, candidate.id)"
@mouseleave="handleMouseLeave"
>
<Link :href="route('admin.candidates.show', candidate.id)" class="hover:text-indigo-600 transition-colors">
{{ candidate.user.name }}
</Link>
</div>
<button
v-if="(candidate.documents || []).some(d => d.type === 'cv')"
@click="openCvPreview(candidate)"
class="p-1 text-slate-400 hover:text-indigo-600 transition-colors"
title="Voir le CV"
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</button>
</div>
<div class="text-xs text-slate-500">{{ candidate.user.email }}</div>
<!-- AI Analysis Popover -->
<div
v-if="hoveredCandidateId === candidate.id && candidate.ai_analysis"
class="fixed z-[100] w-80 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 rounded-xl shadow-2xl p-4 pointer-events-none transition-all animate-in fade-in zoom-in-95 duration-200"
:style="{ top: popoverPosition.top + 'px', left: popoverPosition.left + 'px' }"
>
<div class="flex items-center justify-between mb-3">
<span class="text-[10px] font-black uppercase tracking-widest text-slate-400">Synthèse IA</span>
<div
class="px-2 py-0.5 rounded-lg text-[10px] font-black"
:class="[
candidate.ai_analysis.match_score >= 90 ? 'bg-emerald-50 text-emerald-700' :
candidate.ai_analysis.match_score >= 80 ? 'bg-amber-50 text-amber-700' :
'bg-rose-50 text-rose-700'
]"
>
{{ candidate.ai_analysis.match_score }}% Match
</div>
</div>
<p class="text-xs text-slate-600 dark:text-slate-300 italic mb-4 leading-relaxed">
"{{ candidate.ai_analysis.synthese || candidate.ai_analysis.summary }}"
</p>
<div class="grid grid-cols-2 gap-3">
<div v-if="candidate.ai_analysis.points_forts || candidate.ai_analysis.strengths">
<p class="text-[9px] font-bold uppercase text-emerald-600 mb-1">Forces</p>
<ul class="text-[10px] text-slate-500 space-y-0.5">
<li v-for="s in (candidate.ai_analysis.points_forts || candidate.ai_analysis.strengths).slice(0,3)" :key="s"> {{ s }}</li>
</ul>
</div>
<div v-if="candidate.ai_analysis.points_faibles || candidate.ai_analysis.gaps">
<p class="text-[9px] font-bold uppercase text-rose-600 mb-1">Points d'attention</p>
<ul class="text-[10px] text-slate-500 space-y-0.5">
<li v-for="g in (candidate.ai_analysis.points_faibles || candidate.ai_analysis.gaps).slice(0,3)" :key="g">• {{ g }}</li>
</ul>
</div>
</div>
<div v-if="candidate.ai_analysis.verdict" class="mt-3 pt-3 border-t border-slate-100 dark:border-slate-800">
<span class="text-[9px] font-bold uppercase text-slate-400">Verdict final :</span>
<span class="ml-2 text-[10px] font-black text-indigo-600">{{ candidate.ai_analysis.verdict }}</span>
</div>
</div>
</td>
<td class="px-4 py-3 text-slate-600">
{{ candidate.job_position ? candidate.job_position.title : '--' }}
</td>
<td class="px-4 py-3 text-center text-slate-500 font-medium">
{{ candidate.city ?? '--' }}
</td>
<td class="px-4 py-3">
<div class="flex flex-col items-center">
<span class="text-lg font-black text-indigo-600 dark:text-indigo-400">{{ candidate.weighted_score }}</span>
</div>
</td>
<td class="px-4 py-3">
<div v-if="candidate.ai_analysis" class="flex flex-col items-center">
<div
class="px-2 py-0.5 rounded-lg text-[10px] font-black shadow-sm"
:class="[
candidate.ai_analysis.match_score >= 90 ? 'bg-emerald-50 text-emerald-700 border border-emerald-200' :
candidate.ai_analysis.match_score >= 80 ? 'bg-highlight/10 text-[#3a2800] border border-highlight/30' :
'bg-accent/10 text-accent border border-accent/20'
]"
>
{{ candidate.ai_analysis.match_score }}%
</div>
</div>
<div v-else class="text-center text-[10px] text-slate-300 italic">--</div>
</td>
<td class="px-4 py-3 text-center font-medium text-slate-600">
{{ candidate.cv_score ?? '--' }}
</td>
<td class="px-4 py-3 text-center font-medium text-slate-600">
{{ candidate.motivation_score ?? '--' }}
</td>
<td class="px-4 py-3 text-center font-medium text-slate-600">
{{ (() => {
const bestAttempt = (candidate.attempts || []).filter(a => a.finished_at).map(a => a.max_score > 0 ? (a.score / a.max_score) * 20 : 0);
return bestAttempt.length ? Math.max(...bestAttempt).toFixed(1) : '--';
})() }}
</td>
<td class="px-4 py-3 text-center font-medium text-slate-600">
{{ candidate.interview_score ?? '--' }}
</td>
</tr>
</template>
<template #footer>
<tr v-if="filteredCandidates.length === 0">
<td colspan="10" class="px-6 py-12 text-center text-slate-500 italic">
Aucun candidat sélectionné ne correspond.
</td>
</tr>
</template>
</draggable>
</table>
</div>
<!-- Document Preview Modal -->
<Modal :show="!!selectedDocument" @close="selectedDocument = null" max-width="4xl">
<div class="p-6 h-[80vh] flex flex-col">
<div class="flex justify-between items-center mb-4">
<h3 class="text-xl font-bold">Aperçu : {{ selectedDocument.original_name }}</h3>
<SecondaryButton @click="selectedDocument = null">Fermer</SecondaryButton>
</div>
<div class="flex-1 bg-slate-100 dark:bg-slate-900 rounded-lg overflow-hidden border border-slate-200 dark:border-slate-700">
<iframe
v-if="selectedDocument"
:src="route('admin.documents.show', selectedDocument.id)"
class="w-full h-full"
></iframe>
</div>
</div>
</Modal>
</AdminLayout>
</template>

View File

@@ -30,6 +30,7 @@ const detailsForm = useForm({
email: props.candidate.user.email,
phone: props.candidate.phone || '',
linkedin_url: props.candidate.linkedin_url || '',
city: props.candidate.city || '',
});
const updateDetails = () => {
detailsForm.put(route('admin.candidates.update', props.candidate.id), {
@@ -294,6 +295,10 @@ const barColor = (pct) => pct >= 80 ? 'bg-success' : pct >= 60 ? 'bg-highlight'
<svg class="w-3.5 h-3.5 text-ink/30 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 16.92v3a2 2 0 01-2.18 2 19.79 19.79 0 01-8.63-3.07 19.5 19.5 0 01-6-6 19.79 19.79 0 01-3.07-8.67A2 2 0 014.11 2h3a2 2 0 012 1.72c.127.96.361 1.903.7 2.81a2 2 0 01-.45 2.11L8.09 9.91a16 16 0 006 6l1.27-1.27a2 2 0 012.11-.45c.907.339 1.85.573 2.81.7A2 2 0 0122 16.92z"/></svg>
<span>{{ candidate.phone }}</span>
</div>
<div v-if="candidate.city" class="flex items-center gap-2.5 text-xs text-ink/55 font-semibold">
<svg class="w-3.5 h-3.5 text-ink/30 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0118 0z"/><circle cx="12" cy="10" r="3"/></svg>
<span>{{ candidate.city }}</span>
</div>
<a v-if="candidate.linkedin_url" :href="candidate.linkedin_url" target="_blank" class="flex items-center gap-2.5 text-xs text-primary font-bold hover:underline">
<svg class="w-3.5 h-3.5 shrink-0" fill="currentColor" viewBox="0 0 24 24"><path d="M16 8a6 6 0 016 6v7h-4v-7a2 2 0 00-2-2 2 2 0 00-2 2v7h-4v-7a6 6 0 016-6zM2 9h4v12H2z"/><circle cx="4" cy="4" r="2"/></svg>
LinkedIn
@@ -356,8 +361,8 @@ const barColor = (pct) => pct >= 80 ? 'bg-success' : pct >= 60 ? 'bg-highlight'
<div class="flex items-center justify-between mb-3">
<span class="text-[9px] font-black uppercase tracking-[0.16em] text-ink/35">Analyse IA</span>
<div :class="['w-9 h-9 rounded-full flex items-center justify-center text-[11px] font-black',
aiAnalysis.match_score >= 80 ? 'bg-success/12 text-success' :
aiAnalysis.match_score >= 60 ? 'bg-highlight/15 text-highlight-on' : 'bg-accent/10 text-accent']">
aiAnalysis.match_score >= 90 ? 'bg-success/12 text-success' :
aiAnalysis.match_score >= 80 ? 'bg-highlight/15 text-highlight-on' : 'bg-accent/10 text-accent']">
{{ aiAnalysis.match_score }}%
</div>
</div>
@@ -497,8 +502,8 @@ const barColor = (pct) => pct >= 80 ? 'bg-success' : pct >= 60 ? 'bg-highlight'
<!-- Match score + synthèse -->
<div class="flex items-start gap-5 p-5 rounded-xl border border-ink/[0.07] bg-neutral">
<div :class="['w-14 h-14 rounded-full flex items-center justify-center text-lg font-black shrink-0',
aiAnalysis.match_score >= 80 ? 'bg-success/12 text-success' :
aiAnalysis.match_score >= 60 ? 'bg-highlight/15 text-highlight' : 'bg-accent/10 text-accent']">
aiAnalysis.match_score >= 90 ? 'bg-success/12 text-success' :
aiAnalysis.match_score >= 80 ? 'bg-highlight/15 text-highlight' : 'bg-accent/10 text-accent']">
{{ aiAnalysis.match_score }}%
</div>
<div>
@@ -777,7 +782,7 @@ const barColor = (pct) => pct >= 80 ? 'bg-success' : pct >= 60 ? 'bg-highlight'
<div class="p-6 space-y-5">
<h3 class="font-serif font-black text-lg text-primary">Modifier les informations</h3>
<div class="grid md:grid-cols-2 gap-4">
<div v-for="(field, key) in { name:'Nom complet', email:'Email', phone:'Téléphone', linkedin_url:'LinkedIn URL' }" :key="key">
<div v-for="(field, key) in { name:'Nom complet', email:'Email', phone:'Téléphone', city:'Ville', linkedin_url:'LinkedIn URL' }" :key="key">
<label class="text-[9px] font-black uppercase tracking-[0.14em] text-ink/35 mb-1.5 block">{{ field }}</label>
<input v-model="detailsForm[key]" type="text"
class="w-full rounded-[10px] border border-ink/10 bg-neutral px-3 py-2.5 text-sm font-semibold text-ink focus:border-primary focus:ring-2 focus:ring-primary/15 outline-none" />

View File

@@ -79,6 +79,8 @@ Route::middleware('auth')->group(function () {
// Admin Routes
Route::middleware('admin')->prefix('admin')->name('admin.')->group(function () {
Route::get('/comparative', [\App\Http\Controllers\CandidateController::class, 'comparative'])->name('comparative');
Route::get('/candidates/selected', [\App\Http\Controllers\CandidateController::class, 'selectedCandidates'])->name('candidates.selected');
Route::post('/candidates/update-order', [\App\Http\Controllers\CandidateController::class, 'updateOrder'])->name('candidates.update-order');
Route::resource('candidates', \App\Http\Controllers\CandidateController::class)->only(['index', 'store', 'show', 'destroy', 'update']);
Route::patch('/candidates/{candidate}/notes', [\App\Http\Controllers\CandidateController::class, 'updateNotes'])->name('candidates.update-notes');
Route::patch('/candidates/{candidate}/scores', [\App\Http\Controllers\CandidateController::class, 'updateScores'])->name('candidates.update-scores');