Add interactive map to visualize candidates provenance using Leaflet
This commit is contained in:
@@ -55,6 +55,27 @@ class CandidateController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function map()
|
||||||
|
{
|
||||||
|
$candidates = Candidate::with(['user', 'jobPosition'])
|
||||||
|
->whereNotNull('city')
|
||||||
|
->where('city', '!=', '')
|
||||||
|
->get()
|
||||||
|
->map(function($c) {
|
||||||
|
return [
|
||||||
|
'id' => $c->id,
|
||||||
|
'name' => $c->user->name,
|
||||||
|
'city' => $c->city,
|
||||||
|
'job' => $c->jobPosition?->title,
|
||||||
|
'score' => $c->weighted_score
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
return \Inertia\Inertia::render('Admin/Candidates/Map', [
|
||||||
|
'candidates' => $candidates
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
public function updateOrder(Request $request)
|
public function updateOrder(Request $request)
|
||||||
{
|
{
|
||||||
$request->validate([
|
$request->validate([
|
||||||
@@ -286,6 +307,8 @@ class CandidateController extends Controller
|
|||||||
$this->storeDocument($candidate, $file, $type);
|
$this->storeDocument($candidate, $file, $type);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
public function toggleSelection(Candidate $candidate)
|
public function toggleSelection(Candidate $candidate)
|
||||||
{
|
{
|
||||||
$candidate->update([
|
$candidate->update([
|
||||||
|
|||||||
7
package-lock.json
generated
7
package-lock.json
generated
@@ -8,6 +8,7 @@
|
|||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
"marked": "^17.0.4",
|
"marked": "^17.0.4",
|
||||||
"vuedraggable": "^4.1.0"
|
"vuedraggable": "^4.1.0"
|
||||||
},
|
},
|
||||||
@@ -1937,6 +1938,12 @@
|
|||||||
"vite": "^8.0.0"
|
"vite": "^8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/leaflet": {
|
||||||
|
"version": "1.9.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||||
|
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||||
|
"license": "BSD-2-Clause"
|
||||||
|
},
|
||||||
"node_modules/lightningcss": {
|
"node_modules/lightningcss": {
|
||||||
"version": "1.32.0",
|
"version": "1.32.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"leaflet": "^1.9.4",
|
||||||
"marked": "^17.0.4",
|
"marked": "^17.0.4",
|
||||||
"vuedraggable": "^4.1.0"
|
"vuedraggable": "^4.1.0"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,11 @@ const navItems = [
|
|||||||
label: 'Selection',
|
label: 'Selection',
|
||||||
icon: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z',
|
icon: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
route: 'admin.candidates.map',
|
||||||
|
label: 'Carte',
|
||||||
|
icon: 'M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z M15 11a3 3 0 11-6 0 3 3 0 016 0z',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const superAdminItems = [
|
const superAdminItems = [
|
||||||
|
|||||||
114
resources/js/Pages/Admin/Candidates/Map.vue
Normal file
114
resources/js/Pages/Admin/Candidates/Map.vue
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
<script setup>
|
||||||
|
import AdminLayout from '@/Layouts/AdminLayout.vue';
|
||||||
|
import { Head } from '@inertiajs/vue3';
|
||||||
|
import { onMounted, ref } from 'vue';
|
||||||
|
import L from 'leaflet';
|
||||||
|
import 'leaflet/dist/leaflet.css';
|
||||||
|
|
||||||
|
// Fix for Leaflet default icons
|
||||||
|
import icon from 'leaflet/dist/images/marker-icon.png';
|
||||||
|
const DefaultIcon = L.icon({
|
||||||
|
iconUrl: icon,
|
||||||
|
iconSize: [25, 41],
|
||||||
|
iconAnchor: [12, 41],
|
||||||
|
popupAnchor: [1, -34],
|
||||||
|
shadowSize: [41, 41]
|
||||||
|
});
|
||||||
|
L.Marker.prototype.options.icon = DefaultIcon;
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
candidates: Array
|
||||||
|
});
|
||||||
|
|
||||||
|
const mapContainer = ref(null);
|
||||||
|
const isLoading = ref(true);
|
||||||
|
const geocodedCount = ref(0);
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
const map = L.map(mapContainer.value).setView([46.603354, 1.888334], 6); // Centered on France
|
||||||
|
|
||||||
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||||
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
const markers = [];
|
||||||
|
|
||||||
|
for (const candidate of props.candidates) {
|
||||||
|
if (!candidate.city) continue;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Rate limited geocoding (Nominatim allows 1 request/sec ideally)
|
||||||
|
const response = await fetch(`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(candidate.city)}&limit=1`);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data && data.length > 0) {
|
||||||
|
const { lat, lon } = data[0];
|
||||||
|
const marker = L.marker([lat, lon]).addTo(map);
|
||||||
|
marker.bindPopup(`
|
||||||
|
<div class="p-1">
|
||||||
|
<p class="font-bold text-primary">${candidate.name}</p>
|
||||||
|
<p class="text-xs text-slate-500">${candidate.city}</p>
|
||||||
|
<p class="text-xs font-semibold mt-1">${candidate.job || 'Pas de poste'}</p>
|
||||||
|
<p class="text-[10px] bg-indigo-50 text-indigo-600 px-1.5 py-0.5 rounded mt-1 inline-block">Score: ${candidate.score}/20</p>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
markers.push(marker);
|
||||||
|
geocodedCount.value++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Artificial delay to avoid being blocked by Nominatim
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 800));
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Geocoding failed for ${candidate.city}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (markers.length > 0) {
|
||||||
|
const group = new L.featureGroup(markers);
|
||||||
|
map.fitBounds(group.getBounds().pad(0.1));
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading.value = false;
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<Head title="Provenance des Candidats" />
|
||||||
|
|
||||||
|
<AdminLayout>
|
||||||
|
<template #header>
|
||||||
|
Carte de Provenance
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div class="mb-6 flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-2xl font-bold">Localisation des Candidats</h3>
|
||||||
|
<p class="text-sm text-slate-500 mt-1">
|
||||||
|
Visualisez d'où viennent vos candidats sur la carte.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div v-if="isLoading" class="flex items-center gap-2 text-indigo-600 font-bold text-sm bg-indigo-50 px-4 py-2 rounded-xl">
|
||||||
|
<svg class="animate-spin h-4 w-4" viewBox="0 0 24 24">
|
||||||
|
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" fill="none"></circle>
|
||||||
|
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||||
|
</svg>
|
||||||
|
Geocodage en cours ({{ geocodedCount }}/{{ candidates.length }})...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white dark:bg-slate-800 rounded-2xl shadow-sm border border-slate-200 dark:border-slate-700 overflow-hidden">
|
||||||
|
<div ref="mapContainer" class="w-full h-[600px] z-10"></div>
|
||||||
|
</div>
|
||||||
|
</AdminLayout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
/* Leaflet popup customization */
|
||||||
|
.leaflet-popup-content-wrapper {
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1);
|
||||||
|
}
|
||||||
|
.leaflet-popup-tip {
|
||||||
|
box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -80,6 +80,7 @@ Route::middleware('auth')->group(function () {
|
|||||||
Route::middleware('admin')->prefix('admin')->name('admin.')->group(function () {
|
Route::middleware('admin')->prefix('admin')->name('admin.')->group(function () {
|
||||||
Route::get('/comparative', [\App\Http\Controllers\CandidateController::class, 'comparative'])->name('comparative');
|
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::get('/candidates/selected', [\App\Http\Controllers\CandidateController::class, 'selectedCandidates'])->name('candidates.selected');
|
||||||
|
Route::get('/candidates/map', [\App\Http\Controllers\CandidateController::class, 'map'])->name('candidates.map');
|
||||||
Route::post('/candidates/update-order', [\App\Http\Controllers\CandidateController::class, 'updateOrder'])->name('candidates.update-order');
|
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::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}/notes', [\App\Http\Controllers\CandidateController::class, 'updateNotes'])->name('candidates.update-notes');
|
||||||
|
|||||||
Reference in New Issue
Block a user