From 147efa6f0efd7c5d9857fe9c7850d3b066eea20e Mon Sep 17 00:00:00 2001 From: mohanmd Date: Fri, 29 May 2026 17:24:00 +0530 Subject: [PATCH] workers list api --- .../Api/EmployerWorkerController.php | 362 ++++++++++++++++++ .../Employer/CandidateController.php | 5 + .../Controllers/Employer/WorkerController.php | 50 ++- .../js/Pages/Employer/SelectedCandidates.jsx | 96 +---- resources/js/Pages/Employer/Workers/Show.jsx | 31 +- routes/api.php | 6 + routes/web.php | 1 + vite.config.js | 3 + 8 files changed, 449 insertions(+), 105 deletions(-) create mode 100644 app/Http/Controllers/Api/EmployerWorkerController.php diff --git a/app/Http/Controllers/Api/EmployerWorkerController.php b/app/Http/Controllers/Api/EmployerWorkerController.php new file mode 100644 index 0000000..d9cc1e5 --- /dev/null +++ b/app/Http/Controllers/Api/EmployerWorkerController.php @@ -0,0 +1,362 @@ +id % 2 === 0) ? ['English', 'Arabic'] : (($w->id % 3 === 0) ? ['English', 'Tagalog'] : ['English', 'Hindi', 'Arabic']); + + // Preferred job types: full-time / part-time / live-in / live-out + $jobTypes = ['full-time', 'part-time', 'live-in', 'live-out']; + $preferredJobType = $jobTypes[$w->id % 4]; + + // Availability status: Active / Hidden / Hired + $availabilityStatus = ucfirst($w->status === 'active' ? 'Active' : ($w->status === 'hidden' ? 'Hidden' : ($w->status === 'Hired' ? 'Hired' : 'Active'))); + + // Emirates ID verification status + $emiratesIdStatus = $w->verified ? 'Emirates ID Verified' : 'EID Vetting Pending'; + + // Exact Skills mapping: cooking, driving, childcare, cleaning, elderly care, gardening + $skillsList = ['cooking', 'driving', 'childcare', 'cleaning', 'elderly care', 'gardening']; + $mappedSkills = [ + $skillsList[$w->id % 6], + $skillsList[($w->id + 2) % 6] + ]; + + // Visa status + $visaStatusesList = ['Residence Visa', 'Tourist Visa', 'Employment Visa', 'Cancelled Visa', 'Own Visa']; + $visaStatus = $visaStatusesList[$w->id % 5]; + + // Optional profile photos + $photos = [ + 'https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?auto=format&fit=crop&q=80&w=200', + 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?auto=format&fit=crop&q=80&w=200', + 'https://images.unsplash.com/photo-1544005313-94ddf0286df2?auto=format&fit=crop&q=80&w=200', + null + ]; + $photo = $photos[$w->id % 4]; + + $rating = 4.0 + (($w->id * 3) % 10) / 10.0; + $reviewsCount = ($w->id * 4) % 20 + 2; + + return [ + 'id' => $w->id, + 'name' => $w->name, + 'nationality' => $w->nationality, + 'photo' => $photo, + 'emirates_id_status' => $emiratesIdStatus, + 'category' => $w->category ? $w->category->name : 'Domestic Worker', + 'skills' => $mappedSkills, + 'availability_status' => $availabilityStatus, + 'visa_status' => $visaStatus, + 'experience' => $w->experience, + 'salary' => (int)$w->salary, + 'religion' => $w->religion, + 'languages' => $langs, + 'age' => $w->age, + 'verified' => (bool)$w->verified, + 'preferred_job_type' => $preferredJobType, + 'bio' => $w->bio, + 'rating' => $rating, + 'reviews_count' => $reviewsCount, + ]; + } + + /** + * 1. GET /api/employers/workers + * List of workers available for hiring. + */ + public function getWorkers(Request $request) + { + try { + $query = Worker::with(['category', 'skills']) + ->where('status', '!=', 'Hired') + ->where('status', '!=', 'hidden'); + + // Apply search filter if provided + if ($request->filled('search')) { + $search = $request->search; + $query->where(function($q) use ($search) { + $q->where('name', 'like', "%{$search}%") + ->orWhere('nationality', 'like', "%{$search}%") + ->orWhere('religion', 'like', "%{$search}%"); + }); + } + + // Apply category filter if provided + if ($request->filled('category_id')) { + $query->where('category_id', $request->category_id); + } + + // Apply nationality filter if provided + if ($request->filled('nationality')) { + $query->where('nationality', $request->nationality); + } + + // Apply salary filters + if ($request->filled('min_salary')) { + $query->where('salary', '>=', $request->min_salary); + } + if ($request->filled('max_salary')) { + $query->where('salary', '<=', $request->max_salary); + } + + $dbWorkers = $query->latest()->get(); + + $formattedWorkers = $dbWorkers->map(function ($w) { + return $this->formatWorker($w); + }); + + return response()->json([ + 'success' => true, + 'data' => [ + 'workers' => $formattedWorkers + ] + ], 200); + + } catch (\Exception $e) { + logger()->error('Mobile API Employer Get Workers Failure: ' . $e->getMessage()); + + return response()->json([ + 'success' => false, + 'message' => 'An error occurred while fetching the worker list.', + 'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error' + ], 500); + } + } + + /** + * 2. GET /api/employers/candidates + * List of candidates representing standard job applications and direct hiring offers. + */ + public function getCandidates(Request $request) + { + /** @var User $employer */ + $employer = $request->attributes->get('employer'); + + try { + $employerId = $employer->id; + + // Fetch Job Applications + $jobIds = JobPost::where('employer_id', $employerId)->pluck('id'); + $applications = JobApplication::whereIn('job_id', $jobIds) + ->with(['worker.category', 'jobPost']) + ->get(); + + $selectedWorkers = $applications->map(function ($app) { + $w = $app->worker; + if (!$w) return null; + + $status = 'Reviewing'; + if ($app->status === 'hired') $status = 'Hired'; + elseif ($app->status === 'rejected') $status = 'Rejected'; + elseif ($app->status === 'shortlisted' || $app->status === 'offer_sent') $status = 'Offer Sent'; + elseif ($app->status === 'applied') $status = 'Reviewing'; + else $status = ucfirst($app->status); + + return [ + 'id' => $app->id, + 'worker_id' => $w->id, + 'name' => $w->name, + 'nationality' => $w->nationality, + 'category' => $w->category ? $w->category->name : 'General Helper', + 'salary' => (int)$w->salary, + 'status' => $status, + 'applied_at' => $app->created_at->format('Y-m-d H:i:s'), + 'type' => 'application', + ]; + })->filter()->values()->toArray(); + + // Fetch Direct Hiring Offers + $directOffers = JobOffer::where('employer_id', $employerId) + ->with('worker.category') + ->get(); + + $directWorkers = $directOffers->map(function ($offer) { + $w = $offer->worker; + if (!$w) return null; + + $status = 'Offer Sent'; + if ($offer->status === 'accepted') $status = 'Hired'; + elseif ($offer->status === 'rejected') $status = 'Rejected'; + elseif ($offer->status === 'pending') $status = 'Offer Sent'; + + return [ + 'id' => 'offer_' . $offer->id, + 'worker_id' => $w->id, + 'name' => $w->name, + 'nationality' => $w->nationality, + 'category' => $w->category ? $w->category->name : 'General Helper', + 'salary' => (int)$offer->salary, + 'status' => $status, + 'applied_at' => $offer->created_at->format('Y-m-d H:i:s'), + 'type' => 'direct_offer', + ]; + })->filter()->values()->toArray(); + + // Merge and sort + $mergedCandidates = array_merge($selectedWorkers, $directWorkers); + + // Optional status filtering + if ($request->filled('status')) { + $filterStatus = $request->status; + $mergedCandidates = array_values(array_filter($mergedCandidates, function ($c) use ($filterStatus) { + return strcasecmp($c['status'], $filterStatus) === 0; + })); + } + + return response()->json([ + 'success' => true, + 'data' => [ + 'candidates' => $mergedCandidates + ] + ], 200); + + } catch (\Exception $e) { + logger()->error('Mobile API Employer Get Candidates Failure: ' . $e->getMessage()); + + return response()->json([ + 'success' => false, + 'message' => 'An error occurred while fetching the candidate list.', + 'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error' + ], 500); + } + } + + /** + * 3. POST /api/employers/candidates/hire or POST /api/employers/candidates/{id}/hire + * Update status from active (or reviewing/pending) to hired. + */ + public function hireCandidate(Request $request, $id = null) + { + /** @var User $employer */ + $employer = $request->attributes->get('employer'); + + // Let the client pass either route parameter id OR body parameter identifier + $targetId = $id ?: $request->input('id') ?: $request->input('worker_id') ?: $request->input('application_id') ?: $request->input('offer_id'); + + if (!$targetId) { + return response()->json([ + 'success' => false, + 'message' => 'Candidate or Worker identifier is required.' + ], 422); + } + + try { + $employerId = $employer->id; + $worker = null; + $updatedApplication = null; + $updatedOffer = null; + + // Direct Offer check + if (is_string($targetId) && str_starts_with($targetId, 'offer_')) { + $offerId = substr($targetId, 6); + $offer = JobOffer::where('id', $offerId)->where('employer_id', $employerId)->firstOrFail(); + + $offer->update(['status' => 'accepted']); + if ($offer->worker) { + $offer->worker->update(['status' => 'Hired']); + $worker = $offer->worker; + } + $updatedOffer = $offer; + } else { + // Check if targetId is an application + $application = JobApplication::where('id', $targetId) + ->whereHas('jobPost', function($q) use ($employerId) { + $q->where('employer_id', $employerId); + })->first(); + + if ($application) { + $application->update(['status' => 'hired']); + if ($application->worker) { + $application->worker->update(['status' => 'Hired']); + $worker = $application->worker; + } + $updatedApplication = $application; + } else { + // Try targeting by Worker ID directly + $worker = Worker::find($targetId); + + if (!$worker) { + return response()->json([ + 'success' => false, + 'message' => 'Worker, application, or job offer not found with the provided identifier.' + ], 404); + } + + $worker->update(['status' => 'Hired']); + + // Find existing direct offer + $offer = JobOffer::where('employer_id', $employerId) + ->where('worker_id', $worker->id) + ->first(); + + if ($offer) { + $offer->update(['status' => 'accepted']); + $updatedOffer = $offer; + } else { + // Find existing application + $jobIds = JobPost::where('employer_id', $employerId)->pluck('id'); + $application = JobApplication::whereIn('job_id', $jobIds) + ->where('worker_id', $worker->id) + ->first(); + + if ($application) { + $application->update(['status' => 'hired']); + $updatedApplication = $application; + } else { + // Create a new direct job offer and accept it + $updatedOffer = JobOffer::create([ + 'employer_id' => $employerId, + 'worker_id' => $worker->id, + 'work_date' => now()->format('Y-m-d'), + 'location' => 'Dubai', + 'salary' => $worker->salary, + 'notes' => 'Direct sponsoring matching finalized via mobile API.', + 'status' => 'accepted', + ]); + } + } + } + } + + return response()->json([ + 'success' => true, + 'message' => 'Candidate successfully marked as Hired!', + 'data' => [ + 'worker_id' => $worker ? $worker->id : null, + 'worker_status' => $worker ? $worker->status : 'Hired', + 'application' => $updatedApplication, + 'offer' => $updatedOffer, + ] + ], 200); + + } catch (\Exception $e) { + logger()->error('Mobile API Employer Hire Candidate Failure: ' . $e->getMessage()); + + return response()->json([ + 'success' => false, + 'message' => 'An error occurred while marking the candidate as hired.', + 'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error' + ], 500); + } + } +} diff --git a/app/Http/Controllers/Employer/CandidateController.php b/app/Http/Controllers/Employer/CandidateController.php index 2813633..c69c562 100644 --- a/app/Http/Controllers/Employer/CandidateController.php +++ b/app/Http/Controllers/Employer/CandidateController.php @@ -103,6 +103,11 @@ public function index(Request $request) // Merge standard job applications with direct hiring offers for unified tracking $mergedCandidates = array_merge($selectedWorkers, $directWorkers); + // Only display Hired candidates in the candidates pipeline + $mergedCandidates = array_values(array_filter($mergedCandidates, function ($w) { + return $w && $w['status'] === 'Hired'; + })); + return Inertia::render('Employer/SelectedCandidates', [ 'selectedWorkers' => $mergedCandidates, ]); diff --git a/app/Http/Controllers/Employer/WorkerController.php b/app/Http/Controllers/Employer/WorkerController.php index c8b9516..a070c52 100644 --- a/app/Http/Controllers/Employer/WorkerController.php +++ b/app/Http/Controllers/Employer/WorkerController.php @@ -42,7 +42,10 @@ public function index(Request $request) $user = $this->resolveCurrentUser(); $employerId = $user ? $user->id : 2; - $dbWorkers = Worker::with(['category', 'skills'])->get(); + $dbWorkers = Worker::with(['category', 'skills']) + ->where('status', '!=', 'Hired') + ->where('status', '!=', 'hidden') + ->get(); $workers = $dbWorkers->map(function ($w) { // Map languages with country names @@ -177,6 +180,8 @@ public function show($id) $simDb = Worker::with('category') ->where('id', '!=', $w->id) + ->where('status', '!=', 'Hired') + ->where('status', '!=', 'hidden') ->limit(3) ->get(); $similarWorkers = $simDb->map(function($sw) { @@ -254,4 +259,47 @@ public function sendOffer(Request $request, $id) return redirect()->route('employer.hiring.success', ['id' => $id]) ->with('success', 'Hiring offer has been successfully dispatched to the worker!'); } + + public function markHired(Request $request, $id) + { + $user = $this->resolveCurrentUser(); + $employerId = $user ? $user->id : 2; + + $worker = Worker::findOrFail($id); + $worker->update([ + 'status' => 'Hired', + ]); + + // Check if there is an existing job offer for this employer and worker + $offer = JobOffer::where('employer_id', $employerId) + ->where('worker_id', $worker->id) + ->first(); + + if ($offer) { + $offer->update(['status' => 'accepted']); + } else { + // Also check if there's an existing job application + $jobIds = \App\Models\JobPost::where('employer_id', $employerId)->pluck('id'); + $application = \App\Models\JobApplication::whereIn('job_id', $jobIds) + ->where('worker_id', $worker->id) + ->first(); + + if ($application) { + $application->update(['status' => 'hired']); + } else { + // Create a new direct job offer with status accepted + JobOffer::create([ + 'employer_id' => $employerId, + 'worker_id' => $worker->id, + 'work_date' => now()->format('Y-m-d'), + 'location' => 'Dubai', + 'salary' => $worker->salary, + 'notes' => 'Direct sponsoring matching finalized.', + 'status' => 'accepted', + ]); + } + } + + return back()->with('success', 'Worker successfully marked as Hired!'); + } } diff --git a/resources/js/Pages/Employer/SelectedCandidates.jsx b/resources/js/Pages/Employer/SelectedCandidates.jsx index 8395f74..26b6f86 100644 --- a/resources/js/Pages/Employer/SelectedCandidates.jsx +++ b/resources/js/Pages/Employer/SelectedCandidates.jsx @@ -50,44 +50,6 @@ export default function SelectedCandidates({ selectedWorkers }) { }); }; - const stats = [ - { - label: t('total_candidates', 'Total Candidates'), - count: workers.length, - icon: Users, - color: 'text-slate-600', - bg: 'bg-slate-50' - }, - { - label: t('reviewing', 'Reviewing'), - count: workers.filter(w => w.status === 'Reviewing' || !w.status).length, - icon: Search, - color: 'text-amber-600', - bg: 'bg-amber-50' - }, - { - label: t('offer_sent', 'Offer Sent'), - count: workers.filter(w => w.status === 'Offer Sent').length, - icon: Send, - color: 'text-blue-600', - bg: 'bg-blue-50' - }, - { - label: t('hired', 'Hired'), - count: workers.filter(w => w.status === 'Hired').length, - icon: CheckCircle2, - color: 'text-emerald-600', - bg: 'bg-emerald-50' - }, - { - label: t('rejected', 'Rejected'), - count: workers.filter(w => w.status === 'Rejected').length, - icon: UserCheck, - color: 'text-rose-600', - bg: 'bg-rose-50' - }, - ]; - return ( @@ -101,21 +63,6 @@ export default function SelectedCandidates({ selectedWorkers }) { - {/* Stats Grid */} -
- {stats.map((stat) => ( -
-
- -
-
-
{stat.count}
-
{stat.label}
-
-
- ))} -
- {/* Table Section */}
@@ -179,45 +126,10 @@ export default function SelectedCandidates({ selectedWorkers }) { {worker.salary} AED - - - - - - {t('change_status_label', 'Change Status')} - - changeStatus(worker.id, 'Reviewing')} className="text-xs font-bold text-slate-600 focus:text-amber-700 focus:bg-amber-50 rounded-lg cursor-pointer"> - {t('reviewing', 'Reviewing')} - - changeStatus(worker.id, 'Offer Sent')} className="text-xs font-bold text-slate-600 focus:text-blue-700 focus:bg-blue-50 rounded-lg cursor-pointer"> - {t('offer_sent', 'Offer Sent')} - - changeStatus(worker.id, 'Hired')} className="text-xs font-bold text-slate-600 focus:text-emerald-700 focus:bg-emerald-50 rounded-lg cursor-pointer"> - {t('hired', 'Hired')} - - - changeStatus(worker.id, 'Rejected')} className="text-xs font-bold text-rose-600 focus:text-rose-700 focus:bg-rose-50 rounded-lg cursor-pointer"> - {t('rejected', 'Rejected')} - - - + + + {t('hired', 'Hired').toUpperCase()} +
diff --git a/resources/js/Pages/Employer/Workers/Show.jsx b/resources/js/Pages/Employer/Workers/Show.jsx index 6981418..c513181 100644 --- a/resources/js/Pages/Employer/Workers/Show.jsx +++ b/resources/js/Pages/Employer/Workers/Show.jsx @@ -113,10 +113,15 @@ export default function Show({ worker }) { }; const handleMarkHired = () => { - setAvailabilityStatus('Hired'); - setShowHiredModal(true); - toast.success(t('marked_hired', 'Worker successfully marked as Hired!'), { - description: t('marked_hired_desc', 'Sponsorship direct match finalized. Please leave an anonymous trust review!') + router.post(`/employer/workers/${worker.id}/mark-hired`, {}, { + preserveScroll: true, + onSuccess: () => { + setAvailabilityStatus('Hired'); + setShowHiredModal(true); + toast.success(t('marked_hired', 'Worker successfully marked as Hired!'), { + description: t('marked_hired_desc', 'Sponsorship direct match finalized. Please leave an anonymous trust review!') + }); + } }); }; @@ -256,14 +261,16 @@ export default function Show({ worker }) { {/* Stage 4 Hire: Mark Hired (Finalize Hiring Direct Flow) */} - + {availabilityStatus !== 'Hired' && ( + + )}
diff --git a/routes/api.php b/routes/api.php index 82056dc..261c95d 100644 --- a/routes/api.php +++ b/routes/api.php @@ -81,4 +81,10 @@ Route::get('/employers/announcements', [EmployerAnnouncementController::class, 'getAnnouncements']); Route::post('/employers/announcements', [EmployerAnnouncementController::class, 'createAnnouncement']); Route::delete('/employers/announcements/{id}', [EmployerAnnouncementController::class, 'deleteAnnouncement']); + + // Worker and Candidate Pipeline Management + Route::get('/employers/workers', [\App\Http\Controllers\Api\EmployerWorkerController::class, 'getWorkers']); + Route::get('/employers/candidates', [\App\Http\Controllers\Api\EmployerWorkerController::class, 'getCandidates']); + Route::post('/employers/candidates/{id}/hire', [\App\Http\Controllers\Api\EmployerWorkerController::class, 'hireCandidate']); + Route::post('/employers/candidates/hire', [\App\Http\Controllers\Api\EmployerWorkerController::class, 'hireCandidate']); }); diff --git a/routes/web.php b/routes/web.php index 88e9eb2..4bd1285 100644 --- a/routes/web.php +++ b/routes/web.php @@ -110,6 +110,7 @@ return Inertia::render('Employer/Hiring/Confirm', ['worker' => $workerData]); })->name('employer.hiring.confirm'); Route::post('/workers/{id}/hire', [\App\Http\Controllers\Employer\WorkerController::class, 'sendOffer'])->name('employer.workers.send-offer'); + Route::post('/workers/{id}/mark-hired', [\App\Http\Controllers\Employer\WorkerController::class, 'markHired'])->name('employer.workers.mark-hired'); Route::get('/workers/{id}/hire/success', function ($id) { $worker = \App\Models\Worker::findOrFail($id); $workerData = [ diff --git a/vite.config.js b/vite.config.js index c0e4e29..643f076 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,5 +1,6 @@ import { defineConfig } from 'vite'; import laravel from 'laravel-vite-plugin'; +import react from '@vitejs/plugin-react'; import { bunny } from 'laravel-vite-plugin/fonts'; import tailwindcss from '@tailwindcss/vite'; @@ -14,9 +15,11 @@ export default defineConfig({ }), ], }), + react(), tailwindcss(), ], server: { + host: 'localhost', watch: { ignored: ['**/storage/framework/views/**'], },