From e475a7558bc8af9aa8477327fc160222d4d4fdf8 Mon Sep 17 00:00:00 2001 From: mohanmd Date: Mon, 6 Jul 2026 10:36:54 +0530 Subject: [PATCH 01/12] worker api changes hiring, worker/employers api list --- .../Api/EmployerWorkerController.php | 107 +++++---- .../Controllers/Api/WorkerJobController.php | 14 ++ .../Api/WorkerProfileController.php | 205 ++++++++++++++---- .../Employer/CandidateController.php | 45 +++- .../js/Pages/Employer/SelectedCandidates.jsx | 4 +- tests/Feature/EmployerWorkerFilterApiTest.php | 20 +- tests/Feature/WorkerEmployersApiTest.php | 192 ++++++++++++++++ tests/Feature/WorkerJobApiTest.php | 97 +++++++++ 8 files changed, 555 insertions(+), 129 deletions(-) diff --git a/app/Http/Controllers/Api/EmployerWorkerController.php b/app/Http/Controllers/Api/EmployerWorkerController.php index e73f5dd..eedc28f 100644 --- a/app/Http/Controllers/Api/EmployerWorkerController.php +++ b/app/Http/Controllers/Api/EmployerWorkerController.php @@ -90,30 +90,6 @@ private function formatWorker(Worker $w) ] ]; })->toArray(), - 'documents' => $w->documents->map(function ($doc) { - $ocrData = $doc->ocr_data; - if ($doc->type === 'visa' && is_array($ocrData)) { - unset($ocrData['file_number']); - unset($ocrData['name']); - unset($ocrData['passport_number']); - unset($ocrData['accompanied_by']); - unset($ocrData['valid_until']); - unset($ocrData['sponsor']); - } - return [ - 'id' => $doc->id, - 'worker_id' => $doc->worker_id, - 'type' => $doc->type, - 'number' => $doc->number, - 'issue_date' => $doc->issue_date, - 'expiry_date' => $doc->expiry_date, - 'ocr_accuracy' => $doc->ocr_accuracy, - 'file_path' => $doc->file_path ? url($doc->file_path) : null, - 'ocr_data' => $ocrData, - 'created_at' => $doc->created_at?->toISOString(), - 'updated_at' => $doc->updated_at?->toISOString(), - ]; - })->toArray(), 'category' => [ 'id' => 7, 'name' => 'General Helper', @@ -440,10 +416,11 @@ public function getCandidates(Request $request) 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'; + $dbStatusLower = strtolower($app->status ?? ''); + if ($dbStatusLower === 'hired') $status = 'Hired'; + elseif ($dbStatusLower === 'rejected') $status = 'Rejected'; + elseif ($dbStatusLower === 'shortlisted' || $dbStatusLower === 'offer_sent' || $dbStatusLower === 'offer sent') $status = 'Offer Sent'; + elseif ($dbStatusLower === 'applied') $status = 'Reviewing'; else $status = ucfirst($app->status); $formattedWorker = $this->formatWorker($w); @@ -465,9 +442,10 @@ public function getCandidates(Request $request) 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'; + $dbOfferStatusLower = strtolower($offer->status ?? ''); + if ($dbOfferStatusLower === 'accepted' || $dbOfferStatusLower === 'hired') $status = 'Hired'; + elseif ($dbOfferStatusLower === 'rejected') $status = 'Rejected'; + elseif ($dbOfferStatusLower === 'pending' || $dbOfferStatusLower === 'offer sent' || $dbOfferStatusLower === 'offer_sent') $status = 'Offer Sent'; $formattedWorker = $this->formatWorker($w); @@ -480,12 +458,21 @@ public function getCandidates(Request $request) ]); })->filter()->values()->toArray(); - // Merge and sort - $mergedCandidates = array_merge($selectedWorkers, $directWorkers); + // Merge and sort (filtering out duplicates for direct offers where already hired via application) + $hiredAppWorkerIds = collect($selectedWorkers) + ->filter(fn($w) => strtolower($w['status'] ?? '') === 'hired') + ->pluck('worker_id') + ->toArray(); + + $filteredDirectWorkers = array_filter($directWorkers, function($dw) use ($hiredAppWorkerIds) { + return $dw && !in_array($dw['worker_id'], $hiredAppWorkerIds); + }); + + $mergedCandidates = array_merge($selectedWorkers, $filteredDirectWorkers); // Only display Hired candidates in the candidates pipeline (exclude active states) $mergedCandidates = array_values(array_filter($mergedCandidates, function ($c) { - return $c && $c['status'] === 'Hired'; + return $c && strtolower($c['status'] ?? '') === 'hired'; })); // Apply filters: gender, skills, languages, nationality, availability, preferred_location, job_type, live_in_out, in_country, visa_status @@ -810,6 +797,20 @@ public function hireCandidate(Request $request, $id = null) $worker = $application->worker; } $updatedApplication = $application; + // Create/update the JobOffer record to satisfy the Worker Employer List API + JobOffer::updateOrCreate( + [ + 'employer_id' => $employerId, + 'worker_id' => $application->worker_id, + ], + [ + 'work_date' => $application->jobPost->start_date ?? now(), + 'location' => $application->jobPost->location ?? 'Dubai', + 'salary' => $application->jobPost->salary ?? 0, + 'status' => 'accepted', + 'notes' => $application->notes ?? 'Hired through Job Application', + ] + ); } else { // Try targeting by Worker ID directly $worker = Worker::find($targetId); @@ -841,6 +842,20 @@ public function hireCandidate(Request $request, $id = null) if ($application) { $application->update(['status' => 'hired']); $updatedApplication = $application; + // Create/update the JobOffer record to satisfy the Worker Employer List API + JobOffer::updateOrCreate( + [ + 'employer_id' => $employerId, + 'worker_id' => $application->worker_id, + ], + [ + 'work_date' => $application->jobPost->start_date ?? now(), + 'location' => $application->jobPost->location ?? 'Dubai', + 'salary' => $application->jobPost->salary ?? 0, + 'status' => 'accepted', + 'notes' => $application->notes ?? 'Hired through Job Application', + ] + ); } else { // Create a new direct job offer and accept it $updatedOffer = JobOffer::create([ @@ -1049,30 +1064,6 @@ public function getWorkerDetail(Request $request, $id) ] ]; })->toArray(), - 'documents' => $w->documents->map(function ($doc) { - $ocrData = $doc->ocr_data; - if ($doc->type === 'visa' && is_array($ocrData)) { - unset($ocrData['file_number']); - unset($ocrData['name']); - unset($ocrData['passport_number']); - unset($ocrData['accompanied_by']); - unset($ocrData['valid_until']); - unset($ocrData['sponsor']); - } - return [ - 'id' => $doc->id, - 'worker_id' => $doc->worker_id, - 'type' => $doc->type, - 'number' => $doc->number, - 'issue_date' => $doc->issue_date, - 'expiry_date' => $doc->expiry_date, - 'ocr_accuracy' => $doc->ocr_accuracy, - 'file_path' => $doc->file_path ? url($doc->file_path) : null, - 'ocr_data' => $ocrData, - 'created_at' => $doc->created_at?->toISOString(), - 'updated_at' => $doc->updated_at?->toISOString(), - ]; - })->toArray(), ]; return response()->json([ diff --git a/app/Http/Controllers/Api/WorkerJobController.php b/app/Http/Controllers/Api/WorkerJobController.php index 69ebc2a..5ba1945 100644 --- a/app/Http/Controllers/Api/WorkerJobController.php +++ b/app/Http/Controllers/Api/WorkerJobController.php @@ -643,6 +643,20 @@ public function employerUpdateApplicationStatus(Request $request, $id) if ($application->worker) { $application->worker->update(['status' => 'Hired']); } + // Create/update the JobOffer record to satisfy the Worker Employer List API + \App\Models\JobOffer::updateOrCreate( + [ + 'employer_id' => $employer->id, + 'worker_id' => $application->worker_id, + ], + [ + 'work_date' => $application->jobPost->start_date ?? now(), + 'location' => $application->jobPost->location ?? 'Dubai', + 'salary' => $application->jobPost->salary ?? 0, + 'status' => 'accepted', + 'notes' => $application->notes ?? 'Hired through Job Application', + ] + ); } elseif ($dbStatus === 'rejected') { if ($application->worker) { $application->worker->update(['status' => 'active']); diff --git a/app/Http/Controllers/Api/WorkerProfileController.php b/app/Http/Controllers/Api/WorkerProfileController.php index 9f5664c..e339ea2 100644 --- a/app/Http/Controllers/Api/WorkerProfileController.php +++ b/app/Http/Controllers/Api/WorkerProfileController.php @@ -4,6 +4,7 @@ use App\Http\Controllers\Controller; use App\Models\JobOffer; +use App\Models\JobApplication; use App\Models\Worker; use App\Models\WorkerDocument; use App\Models\ProfileView; @@ -587,13 +588,29 @@ public function getDashboard(Request $request) // 3. Currently working sponsor/employer $currentSponsor = null; + $emp = null; + $hiredAt = null; + $acceptedOffer = JobOffer::where('worker_id', $worker->id) - ->where('status', 'accepted') + ->whereIn(DB::raw('LOWER(status)'), ['accepted', 'hired']) ->with(['employer.employerProfile', 'employer.employerReviews.worker']) ->first(); - if ($acceptedOffer && $acceptedOffer->employer) { + if ($acceptedOffer) { $emp = $acceptedOffer->employer; + $hiredAt = $acceptedOffer->updated_at; + } else { + $hiredApplication = \App\Models\JobApplication::where('worker_id', $worker->id) + ->whereIn(DB::raw('LOWER(status)'), ['hired']) + ->with(['jobPost.employer.employerProfile', 'jobPost.employer.employerReviews.worker']) + ->first(); + if ($hiredApplication && $hiredApplication->jobPost && $hiredApplication->jobPost->employer) { + $emp = $hiredApplication->jobPost->employer; + $hiredAt = $hiredApplication->updated_at; + } + } + + if ($emp) { $empProfile = $emp->employerProfile; $currentSponsor = [ 'id' => $emp->id, @@ -602,9 +619,9 @@ public function getDashboard(Request $request) 'nationality' => $empProfile->nationality ?? 'UAE', 'city' => $empProfile->city ?? 'Dubai', 'email' => $emp->email, - 'phone' => $emp->phone, - 'hired_at' => $acceptedOffer->updated_at->toIso8601String(), - 'hired_at_formatted' => $acceptedOffer->updated_at->format('M d, Y'), + 'phone' => $empProfile->phone ?? $emp->phone, + 'hired_at' => $hiredAt ? $hiredAt->toIso8601String() : null, + 'hired_at_formatted' => $hiredAt ? $hiredAt->format('M d, Y') : null, 'rating' => $emp->rating, 'reviews_count' => $emp->reviews_count, 'reviews' => $emp->employerReviews, @@ -687,45 +704,22 @@ public function getEmployers(Request $request) $worker = $request->attributes->get('worker'); try { - $query = JobOffer::where('worker_id', $worker->id) - ->with(['employer.employerProfile', 'employer.employerReviews.worker']); + // Fetch JobOffers + $offers = JobOffer::where('worker_id', $worker->id) + ->with(['employer.employerProfile', 'employer.employerReviews.worker']) + ->get(); - // Filtering by status - if ($request->filled('status')) { - $statusInput = strtolower($request->status); - if ($statusInput === 'active') { - $query->where('status', 'accepted'); - } else { - $query->where('status', $statusInput); - } - } + // Fetch hired JobApplications + $applications = JobApplication::where('worker_id', $worker->id) + ->where('status', 'hired') + ->with(['jobPost.employer.employerProfile', 'jobPost.employer.employerReviews.worker']) + ->get(); - // Sorting - $sortBy = $request->input('sort_by', 'joining_date'); - $sortOrder = strtolower($request->input('sort_order', 'desc')) === 'asc' ? 'asc' : 'desc'; - - $sortColumn = 'work_date'; - if ($sortBy === 'salary') { - $sortColumn = 'salary'; - } elseif ($sortBy === 'status') { - $sortColumn = 'status'; - } elseif ($sortBy === 'created_at') { - $sortColumn = 'created_at'; - } - - // Always display the current active employer (status = accepted) at the top of the list, - // followed by other employers ordered by the chosen sort column. - $query->orderByRaw("CASE WHEN status = 'accepted' THEN 0 ELSE 1 END ASC") - ->orderBy($sortColumn, $sortOrder); - - $perPage = (int) $request->input('per_page', 15); - $paginator = $query->paginate($perPage); - - $employers = collect($paginator->items())->map(function ($offer) { + // Map JobOffers + $mappedOffers = $offers->map(function ($offer) { $emp = $offer->employer; $empProfile = $emp ? $emp->employerProfile : null; - // Map 'accepted' -> 'Active' (and others to Title Case for nice presentation) $statusFormatted = $offer->status; if (strtolower($offer->status) === 'accepted') { $statusFormatted = 'Active'; @@ -736,10 +730,13 @@ public function getEmployers(Request $request) return [ 'offer_id' => $offer->id, 'joining_date' => $offer->work_date ? $offer->work_date->format('Y-m-d') : null, + 'joining_date_raw' => $offer->work_date, 'location' => $offer->location, 'salary' => (int) $offer->salary, 'status' => $statusFormatted, + 'status_raw' => $offer->status, 'notes' => $offer->notes, + 'created_at' => $offer->created_at, 'employer' => $emp ? [ 'id' => $emp->id, 'name' => $emp->name, @@ -755,15 +752,120 @@ public function getEmployers(Request $request) ]; }); + // Map JobApplications + $mappedApps = $applications->map(function ($app) { + $job = $app->jobPost; + $emp = $job ? $job->employer : null; + $empProfile = $emp ? $emp->employerProfile : null; + + return [ + 'offer_id' => $app->id, + 'joining_date' => $app->updated_at ? $app->updated_at->format('Y-m-d') : null, + 'joining_date_raw' => $app->updated_at, + 'location' => $job->location ?? null, + 'salary' => $job ? (int) $job->salary : 0, + 'status' => 'Active', + 'status_raw' => 'accepted', + 'notes' => $app->notes, + 'created_at' => $app->created_at, + 'employer' => $emp ? [ + 'id' => $emp->id, + 'name' => $emp->name, + 'email' => $emp->email, + 'phone' => $emp->phone, + 'company_name' => $empProfile->company_name ?? 'Private Sponsor', + 'city' => $empProfile->city ?? null, + 'nationality' => $empProfile->nationality ?? null, + 'rating' => $emp->rating, + 'reviews_count' => $emp->reviews_count, + 'reviews' => $emp->employerReviews, + ] : null + ]; + }); + + // Merge and de-duplicate by employer id + $merged = $mappedOffers->concat($mappedApps)->unique(function ($item) { + return $item['employer']['id'] ?? null; + })->values(); + + // Filtering by status + if ($request->filled('status')) { + $statusInput = strtolower($request->status); + $merged = $merged->filter(function ($item) use ($statusInput) { + $rawStatus = strtolower($item['status_raw'] ?? ''); + if ($statusInput === 'active') { + return $rawStatus === 'accepted' || $rawStatus === 'hired'; + } + return $rawStatus === $statusInput; + }); + } + + // Sorting + $sortBy = $request->input('sort_by', 'joining_date'); + $sortOrder = strtolower($request->input('sort_order', 'desc')) === 'asc' ? 'asc' : 'desc'; + + $merged = $merged->sort(function ($a, $b) use ($sortBy, $sortOrder) { + // Priority: status = 'Active' (accepted/hired) comes first + $aActive = $a['status'] === 'Active' ? 0 : 1; + $bActive = $b['status'] === 'Active' ? 0 : 1; + + if ($aActive !== $bActive) { + return $aActive <=> $bActive; + } + + $valA = null; + $valB = null; + + if ($sortBy === 'salary') { + $valA = $a['salary']; + $valB = $b['salary']; + } elseif ($sortBy === 'status') { + $valA = $a['status']; + $valB = $b['status']; + } elseif ($sortBy === 'created_at') { + $valA = $a['created_at']; + $valB = $b['created_at']; + } else { + $valA = $a['joining_date_raw']; + $valB = $b['joining_date_raw']; + } + + if ($valA == $valB) { + return 0; + } + + if ($sortOrder === 'asc') { + return $valA > $valB ? 1 : -1; + } else { + return $valA < $valB ? 1 : -1; + } + }); + + // Pagination + $total = $merged->count(); + $perPage = (int) $request->input('per_page', 15); + $currentPage = (int) $request->input('page', 1); + $lastPage = max(1, (int) ceil($total / $perPage)); + + $pageItems = $merged->slice(($currentPage - 1) * $perPage, $perPage)->values(); + + // Clean up temporary sorting/filtering keys + $employers = $pageItems->map(function ($item) { + unset($item['joining_date_raw']); + unset($item['status_raw']); + unset($item['created_at']); + return $item; + }); + return response()->json([ 'success' => true, 'data' => [ 'employers' => $employers, 'pagination' => [ - 'total' => $paginator->total(), - 'per_page' => $paginator->perPage(), - 'current_page' => $paginator->currentPage(), - 'last_page' => $paginator->lastPage(), + 'total' => $total, + 'per_page' => $perPage, + 'current_page' => $currentPage, + 'last_page' => $lastPage, ] ] ], 200); @@ -976,7 +1078,22 @@ public function getEmployerProfile(Request $request, $id) ->where('worker_id', $worker->id) ->exists(); - if ($hasOffer || $hasApplication || $hasConversation) { + // If the worker has 'Hired' status (case-insensitive) and is hired by this employer + $isHiredByThisEmployer = false; + if (strtolower($worker->status ?? '') === 'hired') { + $isHiredByThisEmployer = \App\Models\JobOffer::where('employer_id', $employer->id) + ->where('worker_id', $worker->id) + ->whereIn(DB::raw('LOWER(status)'), ['accepted', 'hired']) + ->exists() || + \App\Models\JobApplication::where('worker_id', $worker->id) + ->whereHas('jobPost', function ($query) use ($employer) { + $query->where('employer_id', $employer->id); + }) + ->where(DB::raw('LOWER(status)'), 'hired') + ->exists(); + } + + if ($hasOffer || $hasApplication || $hasConversation || $isHiredByThisEmployer) { $hasAccess = true; } diff --git a/app/Http/Controllers/Employer/CandidateController.php b/app/Http/Controllers/Employer/CandidateController.php index a7f22f6..59d41d2 100644 --- a/app/Http/Controllers/Employer/CandidateController.php +++ b/app/Http/Controllers/Employer/CandidateController.php @@ -55,10 +55,11 @@ public function index(Request $request) // Map DB status to UI friendly status $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'; + $dbStatusLower = strtolower($app->status ?? ''); + if ($dbStatusLower === 'hired') $status = 'Hired'; + elseif ($dbStatusLower === 'rejected') $status = 'Rejected'; + elseif ($dbStatusLower === 'shortlisted' || $dbStatusLower === 'offer_sent' || $dbStatusLower === 'offer sent') $status = 'Offer Sent'; + elseif ($dbStatusLower === 'applied') $status = 'Reviewing'; else $status = ucfirst($app->status); return [ @@ -83,15 +84,27 @@ public function index(Request $request) ->with('worker') ->get(); - $directWorkers = $directOffers->map(function ($offer) { + // Get worker IDs of all hired job applications for this employer to prevent duplication + $hiredAppWorkerIds = collect($applications) + ->filter(fn($app) => strtolower($app->status ?? '') === 'hired') + ->pluck('worker_id') + ->toArray(); + + $directWorkers = $directOffers->map(function ($offer) use ($hiredAppWorkerIds) { $w = $offer->worker; if (!$w) return null; + // Avoid duplicate hired entries if worker was hired via standard application + $dbOfferStatusLower = strtolower($offer->status ?? ''); + if (($dbOfferStatusLower === 'accepted' || $dbOfferStatusLower === 'hired') && in_array($w->id, $hiredAppWorkerIds)) { + return null; + } + // Map DB offer status to UI friendly status $status = 'Offer Sent'; - if ($offer->status === 'accepted') $status = 'Hired'; - elseif ($offer->status === 'rejected') $status = 'Rejected'; - elseif ($offer->status === 'pending') $status = 'Offer Sent'; + if ($dbOfferStatusLower === 'accepted' || $dbOfferStatusLower === 'hired') $status = 'Hired'; + elseif ($dbOfferStatusLower === 'rejected') $status = 'Rejected'; + elseif ($dbOfferStatusLower === 'pending' || $dbOfferStatusLower === 'offer sent' || $dbOfferStatusLower === 'offer_sent') $status = 'Offer Sent'; return [ 'id' => 'offer_' . $offer->id, // Unique string key prefix to prevent clashes @@ -115,7 +128,7 @@ public function index(Request $request) // Only display Hired candidates in the candidates pipeline $mergedCandidates = array_values(array_filter($mergedCandidates, function ($w) { - return $w && $w['status'] === 'Hired'; + return $w && strtolower($w['status'] ?? '') === 'hired'; })); // Apply filters: preferred_location, job_type, live_in_out, nationality, in_country, visa_status @@ -411,6 +424,20 @@ public function updateStatus(Request $request, $id) if ($app->worker) { $app->worker->update(['status' => 'Hired']); } + // Create/update the JobOffer record to satisfy the Worker Employer List API + JobOffer::updateOrCreate( + [ + 'employer_id' => $user->id, + 'worker_id' => $app->worker_id, + ], + [ + 'work_date' => $app->jobPost->start_date ?? now(), + 'location' => $app->jobPost->location ?? 'Dubai', + 'salary' => $app->jobPost->salary ?? 0, + 'status' => 'accepted', + 'notes' => $app->notes ?? 'Hired through Job Application', + ] + ); } elseif ($dbStatus === 'rejected') { if ($app->worker) { $app->worker->update(['status' => 'active']); diff --git a/resources/js/Pages/Employer/SelectedCandidates.jsx b/resources/js/Pages/Employer/SelectedCandidates.jsx index c8a822a..454e2f8 100644 --- a/resources/js/Pages/Employer/SelectedCandidates.jsx +++ b/resources/js/Pages/Employer/SelectedCandidates.jsx @@ -78,7 +78,7 @@ export default function SelectedCandidates({ // Apply Client-Side Filters const filteredWorkers = useMemo(() => { - let list = (selectedWorkers || []).filter(w => w.status !== 'Searching'); + let list = (workers || []).filter(w => w.status !== 'Searching'); if (searchTerm.trim() !== '') { const query = searchTerm.toLowerCase(); @@ -134,7 +134,7 @@ export default function SelectedCandidates({ } return list; - }, [selectedWorkers, searchTerm, filterProfession, filterLocation, filterJobType, filterAccommodation, filterNationalities, filterGender, filterInCountry, filterVisaType, filterSkills, filterLanguages, maxSalary]); + }, [workers, searchTerm, filterProfession, filterLocation, filterJobType, filterAccommodation, filterNationalities, filterGender, filterInCountry, filterVisaType, filterSkills, filterLanguages, maxSalary]); const resetFilters = () => { setFilterProfession('All Professions'); diff --git a/tests/Feature/EmployerWorkerFilterApiTest.php b/tests/Feature/EmployerWorkerFilterApiTest.php index 627d229..c389a90 100644 --- a/tests/Feature/EmployerWorkerFilterApiTest.php +++ b/tests/Feature/EmployerWorkerFilterApiTest.php @@ -541,11 +541,8 @@ public function test_worker_list_api_has_full_details_and_pagination() $this->assertCount(1, $matchingWorker['skills']); $this->assertEquals('Ironing', $matchingWorker['skills'][0]['name']); - // Check documents object array - $this->assertIsArray($matchingWorker['documents']); - $this->assertCount(2, $matchingWorker['documents']); - $this->assertEquals('passport', $matchingWorker['documents'][0]['type']); - $this->assertEquals('visa', $matchingWorker['documents'][1]['type']); + // Check documents object array is not present + $this->assertArrayNotHasKey('documents', $matchingWorker); } public function test_worker_detail_api_has_full_details_without_religion_and_cleaned_ocr_data() @@ -618,16 +615,7 @@ public function test_worker_detail_api_has_full_details_without_religion_and_cle // Assert religion is removed $this->assertArrayNotHasKey('religion', $wData); - // Check documents object array - $this->assertIsArray($wData['documents']); - $this->assertCount(2, $wData['documents']); - - // Assert visa ocr_data is cleaned up - $visaDoc = collect($wData['documents'])->firstWhere('type', 'visa'); - $this->assertNotNull($visaDoc); - $this->assertArrayNotHasKey('sponsor', $visaDoc['ocr_data']); - $this->assertArrayNotHasKey('valid_until', $visaDoc['ocr_data']); - $this->assertEquals('some sponsor name', $visaDoc['ocr_data']['sponsor_name']); - $this->assertEquals('2024-02-15', $visaDoc['ocr_data']['expiry_date']); + // Check documents object array is not present + $this->assertArrayNotHasKey('documents', $wData); } } diff --git a/tests/Feature/WorkerEmployersApiTest.php b/tests/Feature/WorkerEmployersApiTest.php index 85b130e..f744dbd 100644 --- a/tests/Feature/WorkerEmployersApiTest.php +++ b/tests/Feature/WorkerEmployersApiTest.php @@ -404,4 +404,196 @@ public function test_worker_can_view_employer_profile_without_permissions_masks_ $response->assertJsonPath('data.employer.email', null); // Masked/null since no connection exists $response->assertJsonPath('data.employer.phone', null); } + + public function test_worker_hired_via_job_application_appears_in_employers_list() + { + // 1. Create a worker + $worker = Worker::create([ + 'name' => 'Hired Worker', + 'email' => 'hiredworker@example.com', + 'phone' => '+971501234567', + 'language' => 'HI', + 'password' => bcrypt('password'), + 'nationality' => 'Indian', + 'age' => 25, + 'salary' => 1500, + 'availability' => 'Immediate', + 'experience' => 'Not Specified', + 'religion' => 'Not Specified', + 'bio' => 'Test', + 'verified' => true, + 'status' => 'active', + 'api_token' => 'worker-hired-token', + ]); + + WorkerDocument::create([ + 'worker_id' => $worker->id, + 'type' => 'passport', + 'number' => '123456', + 'file_path' => 'passports/test.pdf', + ]); + + // 2. Create employer with active subscription + $employer = User::create([ + 'name' => 'Premium Employer', + 'email' => 'premium_emp@example.com', + 'password' => bcrypt('password'), + 'role' => 'employer', + 'subscription_status' => 'active', + 'api_token' => 'premium_employer_token', + ]); + + EmployerProfile::create([ + 'user_id' => $employer->id, + 'company_name' => 'Premium Corp Ltd', + 'nationality' => 'UAE', + 'city' => 'Abu Dhabi', + ]); + + \App\Models\Subscription::create([ + 'user_id' => $employer->id, + 'plan_id' => 'premium', + 'amount_aed' => 100.00, + 'starts_at' => now(), + 'expires_at' => now()->addDays(30), + 'status' => 'active', + ]); + + // 3. Create a JobPost + $job = \App\Models\JobPost::create([ + 'employer_id' => $employer->id, + 'title' => 'Housekeeper Needed', + 'workers_needed' => 1, + 'location' => 'Abu Dhabi', + 'salary' => 2000, + 'job_type' => 'Full Time', + 'start_date' => now()->addDays(5), + 'description' => 'Test job', + 'requirements' => 'None', + 'status' => 'active', + ]); + + // 4. Create JobApplication + $application = \App\Models\JobApplication::create([ + 'job_id' => $job->id, + 'worker_id' => $worker->id, + 'status' => 'applied', + 'notes' => 'Looking forward to work', + ]); + + // Verify employer list is empty for the worker initially + $response = $this->withHeaders([ + 'Authorization' => 'Bearer worker-hired-token', + ])->getJson('/api/workers/employers'); + + $response->assertStatus(200); + $response->assertJsonCount(0, 'data.employers'); + + // 5. Employer hires candidate via API + $response = $this->postJson("/api/employers/applications/{$application->id}/status", [ + 'status' => 'hired', + 'notes' => 'Hired and approved' + ], [ + 'Authorization' => 'Bearer premium_employer_token' + ]); + + $response->assertStatus(200); + + // Verify that the JobOffer with accepted status has been created + $this->assertDatabaseHas('job_offers', [ + 'employer_id' => $employer->id, + 'worker_id' => $worker->id, + 'status' => 'accepted', + ]); + + // 6. Hit the worker employers API and assert that the employer is now listed + $response = $this->withHeaders([ + 'Authorization' => 'Bearer worker-hired-token', + ])->getJson('/api/workers/employers'); + + $response->assertStatus(200); + $response->assertJsonCount(1, 'data.employers'); + $response->assertJsonPath('data.employers.0.employer.name', 'Premium Employer'); + $response->assertJsonPath('data.employers.0.employer.company_name', 'Premium Corp Ltd'); + $response->assertJsonPath('data.employers.0.status', 'Active'); + } + + public function test_worker_hired_via_job_application_appears_without_job_offer() + { + // 1. Create a worker + $worker = Worker::create([ + 'name' => 'Hired Worker Legacy', + 'email' => 'legacyworker@example.com', + 'phone' => '+971501234560', + 'language' => 'HI', + 'password' => bcrypt('password'), + 'nationality' => 'Indian', + 'age' => 25, + 'salary' => 1500, + 'availability' => 'Immediate', + 'experience' => 'Not Specified', + 'religion' => 'Not Specified', + 'bio' => 'Test', + 'verified' => true, + 'status' => 'active', + 'api_token' => 'worker-legacy-token', + ]); + + WorkerDocument::create([ + 'worker_id' => $worker->id, + 'type' => 'passport', + 'number' => '123456', + 'file_path' => 'passports/test.pdf', + ]); + + // 2. Create employer + $employer = User::create([ + 'name' => 'Legacy Employer', + 'email' => 'legacy_emp@example.com', + 'password' => bcrypt('password'), + 'role' => 'employer', + 'subscription_status' => 'active', + 'api_token' => 'legacy_employer_token', + ]); + + EmployerProfile::create([ + 'user_id' => $employer->id, + 'company_name' => 'Legacy Corp Ltd', + 'nationality' => 'UAE', + 'city' => 'Dubai', + ]); + + // 3. Create a JobPost + $job = \App\Models\JobPost::create([ + 'employer_id' => $employer->id, + 'title' => 'Housekeeper Needed Legacy', + 'workers_needed' => 1, + 'location' => 'Dubai', + 'salary' => 2000, + 'job_type' => 'Full Time', + 'start_date' => now()->addDays(5), + 'description' => 'Test job', + 'requirements' => 'None', + 'status' => 'active', + ]); + + // 4. Create JobApplication with status 'hired' directly, and NO JobOffer + $application = \App\Models\JobApplication::create([ + 'job_id' => $job->id, + 'worker_id' => $worker->id, + 'status' => 'hired', + 'notes' => 'Hired directly in DB previously', + ]); + + // 5. Hit the worker employers API and assert that the employer is listed + $response = $this->withHeaders([ + 'Authorization' => 'Bearer worker-legacy-token', + ])->getJson('/api/workers/employers'); + + $response->assertStatus(200); + $response->assertJsonCount(1, 'data.employers'); + $response->assertJsonPath('data.employers.0.employer.name', 'Legacy Employer'); + $response->assertJsonPath('data.employers.0.employer.company_name', 'Legacy Corp Ltd'); + $response->assertJsonPath('data.employers.0.status', 'Active'); + } } diff --git a/tests/Feature/WorkerJobApiTest.php b/tests/Feature/WorkerJobApiTest.php index 5268371..68c8217 100644 --- a/tests/Feature/WorkerJobApiTest.php +++ b/tests/Feature/WorkerJobApiTest.php @@ -507,4 +507,101 @@ public function test_job_hiring_workflow_enhancements() 'id' => $app->id ]); } + + public function test_hired_worker_visibility_and_profile_access() + { + // Create EmployerProfile with a phone number + \App\Models\EmployerProfile::create([ + 'user_id' => $this->employer->id, + 'company_name' => 'Premium Sponsoring Group', + 'phone' => '971501234567', + 'city' => 'Dubai', + 'nationality' => 'UAE', + ]); + + // 1. Create a job post + $job = JobPost::create([ + 'employer_id' => $this->employer->id, + 'title' => 'Special Hired Job', + 'location' => 'Dubai Jumeirah', + 'salary' => 4500, + 'workers_needed' => 1, + 'job_type' => 'Full Time', + 'start_date' => now()->addDays(5)->format('Y-m-d'), + 'description' => 'A special job details description', + 'status' => 'active', + ]); + + // 2. Worker applies for the job + $response = $this->postJson("/api/workers/jobs/{$job->id}/apply", [], [ + 'Authorization' => 'Bearer worker_api_token' + ]); + $response->assertStatus(201); + $app = JobApplication::first(); + + // 3. Employer hires the worker by setting status to 'hired' (lowercase, as in the database) + $response = $this->postJson("/api/employers/applications/{$app->id}/status", [ + 'status' => 'hired', + 'notes' => 'Hired through job flow' + ], [ + 'Authorization' => 'Bearer employer_api_token' + ]); + $response->assertStatus(200); + + // Verify status updates + $this->assertDatabaseHas('job_applications', [ + 'id' => $app->id, + 'status' => 'hired', + ]); + $this->assertDatabaseHas('workers', [ + 'id' => $this->worker->id, + 'status' => 'Hired', + ]); + + // 4. Verify candidate is visible in Hired list (web: CandidateController::index) + $response = $this->actingAs($this->employer) + ->withSession(['user' => $this->employer]) + ->get('/employer/candidates'); + $response->assertStatus(200); + // Extract the Inertia prop 'selectedWorkers' to check + $inertiaData = $response->original->getData(); + $selectedWorkers = $inertiaData['page']['props']['selectedWorkers'] ?? []; + $hiredList = array_values(array_filter($selectedWorkers, function ($w) { + return $w['worker_id'] === $this->worker->id; + })); + $this->assertCount(1, $hiredList); + $this->assertEquals('Hired', $hiredList[0]['status']); + + // 5. Verify candidate is visible in Hired list (API: EmployerWorkerController::getCandidates) + $response = $this->getJson('/api/employers/candidates', [ + 'Authorization' => 'Bearer employer_api_token' + ]); + $response->assertStatus(200); + $response->assertJsonPath('success', true); + // Find the worker in candidates array + $candidates = $response->json('data.candidates'); + $hiredApiList = array_values(array_filter($candidates, function ($c) { + return $c['worker_id'] === $this->worker->id; + })); + $this->assertCount(1, $hiredApiList); + $this->assertEquals('Hired', $hiredApiList[0]['status']); + + // 6. Verify hired worker can view employer profile details bypassing subscription limits (WorkerProfileController::getEmployerProfile) + $response = $this->getJson("/api/workers/employers/{$this->employer->id}", [ + 'Authorization' => 'Bearer worker_api_token' + ]); + $response->assertStatus(200); + $response->assertJsonPath('success', true); + $response->assertJsonPath('data.employer.email', 'employer@example.com'); + $response->assertJsonPath('data.employer.phone', '971501234567'); // should be exposed/visible because worker is hired + + // 7. Verify hired worker dashboard shows correct currently working sponsor + $response = $this->getJson('/api/workers/dashboard', [ + 'Authorization' => 'Bearer worker_api_token' + ]); + $response->assertStatus(200); + $response->assertJsonPath('success', true); + $response->assertJsonPath('data.current_employer.id', $this->employer->id); + $response->assertJsonPath('data.currently_working_sponsor.id', $this->employer->id); + } } -- 2.43.0 From 88e4f3fdb40ddaeab7c226544b281b9792f9e1e4 Mon Sep 17 00:00:00 2001 From: mohanmd Date: Mon, 6 Jul 2026 15:05:22 +0530 Subject: [PATCH 02/12] job sent for worker job list --- .../Controllers/Admin/WorkerController.php | 2 +- .../Api/EmployerWorkerController.php | 98 ++---- .../Controllers/Api/WorkerAuthController.php | 2 +- .../Controllers/Api/WorkerJobController.php | 330 ++++++++++++++++-- .../Api/WorkerProfileController.php | 9 +- .../Employer/CandidateController.php | 87 ++--- .../Controllers/Employer/JobController.php | 115 +++++- .../Employer/MessageController.php | 25 +- .../Controllers/Employer/WorkerController.php | 51 ++- app/Models/JobPost.php | 5 + app/Notifications/GenericNotification.php | 65 ++++ ...ofession_and_skills_to_job_posts_table.php | 37 ++ .../js/Pages/Employer/Jobs/Applicants.jsx | 12 +- resources/js/Pages/Employer/Jobs/Create.jsx | 177 ++++++---- resources/js/Pages/Employer/Jobs/Edit.jsx | 223 +++++++----- resources/js/Pages/Employer/Messages/Show.jsx | 14 +- resources/js/Pages/Employer/Workers/Show.jsx | 106 ++++-- routes/api.php | 2 + tests/Feature/EmployerJobTest.php | 72 +++- tests/Feature/WorkerEmployersApiTest.php | 6 + tests/Feature/WorkerJobApiTest.php | 128 ++++++- 21 files changed, 1198 insertions(+), 368 deletions(-) create mode 100644 app/Notifications/GenericNotification.php create mode 100644 database/migrations/2026_07_06_091221_add_main_profession_and_skills_to_job_posts_table.php diff --git a/app/Http/Controllers/Admin/WorkerController.php b/app/Http/Controllers/Admin/WorkerController.php index c4b9a2a..bcb8a78 100644 --- a/app/Http/Controllers/Admin/WorkerController.php +++ b/app/Http/Controllers/Admin/WorkerController.php @@ -132,7 +132,7 @@ public function updateProfile(Request $request, $id) 'experience' => 'required|string', 'salary' => 'nullable|numeric', 'nationality' => 'nullable|string', - 'visa_status' => 'nullable|string|in:Tourist Visa,Employment Visa,Residence Visa', + 'visa_status' => 'nullable|string', 'age' => 'nullable|integer', 'in_country' => 'nullable', 'preferred_job_type' => 'nullable|string', diff --git a/app/Http/Controllers/Api/EmployerWorkerController.php b/app/Http/Controllers/Api/EmployerWorkerController.php index eedc28f..56faa72 100644 --- a/app/Http/Controllers/Api/EmployerWorkerController.php +++ b/app/Http/Controllers/Api/EmployerWorkerController.php @@ -20,7 +20,7 @@ class EmployerWorkerController extends Controller /** * Helper to map worker DB models to API presentation format. */ - private function formatWorker(Worker $w) + public function formatWorker(Worker $w) { // Map languages from database comma-separated string $langs = $w->language ? array_map('trim', explode(',', $w->language)) : ['English']; @@ -777,11 +777,8 @@ public function hireCandidate(Request $request, $id = null) $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; - } + $offer->update(['status' => 'pending']); + $worker = $offer->worker; $updatedOffer = $offer; } else { // Check if targetId is an application @@ -791,26 +788,9 @@ public function hireCandidate(Request $request, $id = null) })->first(); if ($application) { - $application->update(['status' => 'hired']); - if ($application->worker) { - $application->worker->update(['status' => 'Hired']); - $worker = $application->worker; - } + $application->update(['status' => 'hire_requested']); + $worker = $application->worker; $updatedApplication = $application; - // Create/update the JobOffer record to satisfy the Worker Employer List API - JobOffer::updateOrCreate( - [ - 'employer_id' => $employerId, - 'worker_id' => $application->worker_id, - ], - [ - 'work_date' => $application->jobPost->start_date ?? now(), - 'location' => $application->jobPost->location ?? 'Dubai', - 'salary' => $application->jobPost->salary ?? 0, - 'status' => 'accepted', - 'notes' => $application->notes ?? 'Hired through Job Application', - ] - ); } else { // Try targeting by Worker ID directly $worker = Worker::find($targetId); @@ -822,15 +802,13 @@ public function hireCandidate(Request $request, $id = null) ], 404); } - $worker->update(['status' => 'Hired']); - - // Find existing direct offer + // Find existing direct offer or create a new one with status 'pending' $offer = JobOffer::where('employer_id', $employerId) ->where('worker_id', $worker->id) ->first(); if ($offer) { - $offer->update(['status' => 'accepted']); + $offer->update(['status' => 'pending']); $updatedOffer = $offer; } else { // Find existing application @@ -840,57 +818,57 @@ public function hireCandidate(Request $request, $id = null) ->first(); if ($application) { - $application->update(['status' => 'hired']); + $application->update(['status' => 'hire_requested']); $updatedApplication = $application; - // Create/update the JobOffer record to satisfy the Worker Employer List API - JobOffer::updateOrCreate( - [ - 'employer_id' => $employerId, - 'worker_id' => $application->worker_id, - ], - [ - 'work_date' => $application->jobPost->start_date ?? now(), - 'location' => $application->jobPost->location ?? 'Dubai', - 'salary' => $application->jobPost->salary ?? 0, - 'status' => 'accepted', - 'notes' => $application->notes ?? 'Hired through Job Application', - ] - ); } else { - // Create a new direct job offer and accept it + // Create a new direct job offer in 'pending' status $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', + 'notes' => 'Direct sponsoring matching initiated via API.', + 'status' => 'pending', ]); } } } } - // Dispatch push notification to worker - if ($worker && $worker->fcm_token) { - \App\Services\FCMService::sendPushNotification( - $worker->fcm_token, - "Congratulations! You've been Hired", - "Employer " . ($employer->name ?? "Employer") . " has hired you.", - [ - 'type' => 'hired', - 'employer_id' => $employer->id, - ] - ); + // Dispatch push & database notification to worker for confirmation request + if ($worker) { + if ($updatedApplication) { + $job = $updatedApplication->jobPost; + $worker->notify(new \App\Notifications\GenericNotification( + "Action Required: Confirm Hiring", + "Employer " . ($employer->name ?? "Employer") . " wants to hire you for '" . ($job->title ?? 'Job Post') . "'. Please confirm.", + [ + 'type' => 'hire_request', + 'job_id' => $job->id ?? '', + 'application_id' => $updatedApplication->id, + 'status' => 'hire_requested', + ] + )); + } else if ($updatedOffer) { + $worker->notify(new \App\Notifications\GenericNotification( + "New Job Offer", + "Employer " . ($employer->name ?? "Employer") . " has sent you a direct hire offer.", + [ + 'type' => 'hire_request', + 'offer_id' => $updatedOffer->id, + 'status' => 'pending', + ] + )); + } } return response()->json([ 'success' => true, - 'message' => 'Candidate successfully marked as Hired!', + 'message' => 'Hire request initiated successfully. Awaiting candidate confirmation.', 'data' => [ 'worker_id' => $worker ? $worker->id : null, - 'worker_status' => $worker ? $worker->status : 'Hired', + 'worker_status' => $worker ? $worker->status : 'Active', 'application' => $updatedApplication, 'offer' => $updatedOffer, ] diff --git a/app/Http/Controllers/Api/WorkerAuthController.php b/app/Http/Controllers/Api/WorkerAuthController.php index 84ac32d..384dcf6 100644 --- a/app/Http/Controllers/Api/WorkerAuthController.php +++ b/app/Http/Controllers/Api/WorkerAuthController.php @@ -318,7 +318,7 @@ public function register(Request $request) 'age' => 'nullable|integer', 'experience' => 'nullable|string|max:100', 'in_country' => 'nullable', - 'visa_status' => 'nullable|string|in:Tourist Visa,Employment Visa,Residence Visa', + 'visa_status' => 'nullable|string', 'preferred_job_type' => 'nullable|string|max:100', 'main_profession' => 'nullable|string|max:100', 'gender' => 'nullable|string|in:male,female,other', diff --git a/app/Http/Controllers/Api/WorkerJobController.php b/app/Http/Controllers/Api/WorkerJobController.php index 5ba1945..4db8e37 100644 --- a/app/Http/Controllers/Api/WorkerJobController.php +++ b/app/Http/Controllers/Api/WorkerJobController.php @@ -18,14 +18,38 @@ class WorkerJobController extends Controller */ public function listJobs(Request $request) { - $jobs = JobPost::where('status', 'active') - ->with(['employer.employerProfile']) - ->latest() + /** @var Worker $worker */ + $worker = $request->attributes->get('worker'); + + $query = JobPost::where('status', 'active') + ->with(['employer.employerProfile', 'skills']); + + if ($worker) { + $workerProfession = trim($worker->main_profession); + $workerSkillIds = $worker->skills->pluck('id')->toArray(); + + if ($workerProfession !== '' || !empty($workerSkillIds)) { + $query->where(function($q) use ($workerProfession, $workerSkillIds) { + if ($workerProfession !== '') { + $q->where(\Illuminate\Support\Facades\DB::raw('LOWER(main_profession)'), strtolower($workerProfession)); + } + if (!empty($workerSkillIds)) { + $q->orWhereHas('skills', function ($q2) use ($workerSkillIds) { + $q2->whereIn('skills.id', $workerSkillIds); + }); + } + }); + } + } + + $jobs = $query->latest() ->get() ->map(function ($job) { return [ 'id' => $job->id, 'title' => $job->title, + 'main_profession' => $job->main_profession, + 'skills' => $job->skills->pluck('name')->toArray(), 'location' => $job->location, 'salary' => (int) $job->salary, 'workers_needed' => $job->workers_needed, @@ -54,7 +78,7 @@ public function listJobs(Request $request) public function jobDetails($id) { $job = JobPost::where('status', 'active') - ->with(['employer.employerProfile']) + ->with(['employer.employerProfile', 'skills']) ->find($id); if (!$job) { @@ -70,6 +94,8 @@ public function jobDetails($id) 'job' => [ 'id' => $job->id, 'title' => $job->title, + 'main_profession' => $job->main_profession, + 'skills' => $job->skills->pluck('name')->toArray(), 'location' => $job->location, 'salary' => (int) $job->salary, 'workers_needed' => $job->workers_needed, @@ -121,6 +147,19 @@ public function applyJob(Request $request, $id) 'status' => 'applied' ]); + $employer = $job->employer; + if ($employer) { + $employer->notify(new \App\Notifications\GenericNotification( + "New Job Application", + "A worker (" . ($worker->name ?? 'Candidate') . ") has applied for your job: " . ($job->title ?? 'Job Post'), + [ + 'type' => 'job_application_submitted', + 'job_id' => $job->id, + 'application_id' => $application->id, + ] + )); + } + return response()->json([ 'success' => true, 'message' => 'Successfully applied for the job.', @@ -244,6 +283,8 @@ public function employerCreateJob(Request $request) $validator = Validator::make($request->all(), [ 'title' => 'required|string|max:255', + 'main_profession' => 'required|string|max:255', + 'skills' => 'required', 'workers_needed' => 'required|integer|min:1', 'location' => 'required|string|max:255', 'salary' => 'required|numeric|min:0', @@ -264,6 +305,7 @@ public function employerCreateJob(Request $request) $job = JobPost::create([ 'employer_id' => $employer->id, 'title' => $request->title, + 'main_profession' => $request->main_profession, 'workers_needed' => $request->workers_needed, 'job_type' => $request->job_type, 'location' => $request->location, @@ -274,6 +316,27 @@ public function employerCreateJob(Request $request) 'status' => 'active', ]); + // Sync skills + $skillsInput = $request->skills; + $skillIds = []; + if (is_array($skillsInput)) { + foreach ($skillsInput as $skillNameOrId) { + if (is_numeric($skillNameOrId)) { + $skillIds[] = (int)$skillNameOrId; + } else { + $skill = \App\Models\Skill::firstOrCreate(['name' => trim($skillNameOrId)]); + $skillIds[] = $skill->id; + } + } + } else if (is_string($skillsInput)) { + $skillsArray = array_filter(array_map('trim', explode(',', $skillsInput))); + foreach ($skillsArray as $name) { + $skill = \App\Models\Skill::firstOrCreate(['name' => $name]); + $skillIds[] = $skill->id; + } + } + $job->skills()->sync($skillIds); + return response()->json([ 'success' => true, 'message' => 'Job posted successfully.', @@ -281,6 +344,8 @@ public function employerCreateJob(Request $request) 'job' => [ 'id' => $job->id, 'title' => $job->title, + 'main_profession' => $job->main_profession, + 'skills' => $job->skills()->pluck('name')->toArray(), 'location' => $job->location, 'salary' => (int) $job->salary, 'workers_needed' => $job->workers_needed, @@ -368,8 +433,17 @@ public function employerUpdateJob(Request $request, $id) ], 404); } + if ($job->applications()->exists()) { + return response()->json([ + 'success' => false, + 'message' => 'This job cannot be edited because workers have already applied for it.' + ], 422); + } + $validator = Validator::make($request->all(), [ 'title' => 'required|string|max:255', + 'main_profession' => 'required|string|max:255', + 'skills' => 'required', 'workers_needed' => 'required|integer|min:1', 'location' => 'required|string|max:255', 'salary' => 'required|numeric|min:0', @@ -390,6 +464,7 @@ public function employerUpdateJob(Request $request, $id) $job->update([ 'title' => $request->title, + 'main_profession' => $request->main_profession, 'workers_needed' => $request->workers_needed, 'job_type' => $request->job_type, 'location' => $request->location, @@ -400,6 +475,27 @@ public function employerUpdateJob(Request $request, $id) 'status' => strtolower($request->status), ]); + // Sync skills + $skillsInput = $request->skills; + $skillIds = []; + if (is_array($skillsInput)) { + foreach ($skillsInput as $skillNameOrId) { + if (is_numeric($skillNameOrId)) { + $skillIds[] = (int)$skillNameOrId; + } else { + $skill = \App\Models\Skill::firstOrCreate(['name' => trim($skillNameOrId)]); + $skillIds[] = $skill->id; + } + } + } else if (is_string($skillsInput)) { + $skillsArray = array_filter(array_map('trim', explode(',', $skillsInput))); + foreach ($skillsArray as $name) { + $skill = \App\Models\Skill::firstOrCreate(['name' => $name]); + $skillIds[] = $skill->id; + } + } + $job->skills()->sync($skillIds); + return response()->json([ 'success' => true, 'message' => 'Job updated successfully.', @@ -407,6 +503,8 @@ public function employerUpdateJob(Request $request, $id) 'job' => [ 'id' => $job->id, 'title' => $job->title, + 'main_profession' => $job->main_profession, + 'skills' => $job->skills()->pluck('name')->toArray(), 'location' => $job->location, 'salary' => (int) $job->salary, 'workers_needed' => $job->workers_needed, @@ -562,7 +660,7 @@ public function employerUpdateApplicationStatus(Request $request, $id) } $validator = Validator::make($request->all(), [ - 'status' => 'required|string|in:applied,shortlisted,contacted,interview scheduled,interview_scheduled,selected,rejected,hired', + 'status' => 'required|string|in:applied,shortlisted,contacted,interview scheduled,interview_scheduled,selected,rejected,hired,hire_requested', 'notes' => 'nullable|string', ]); @@ -587,9 +685,12 @@ public function employerUpdateApplicationStatus(Request $request, $id) } $dbStatus = strtolower($request->status); + if ($dbStatus === 'hired') { + $dbStatus = 'hire_requested'; + } - // Contact Limit check: if moving to 'shortlisted', 'contacted', 'interview_scheduled', 'selected', 'hired' for the first time - $contactStatuses = ['shortlisted', 'contacted', 'interview_scheduled', 'selected', 'hired']; + // Contact Limit check: if moving to 'shortlisted', 'contacted', 'interview_scheduled', 'selected', 'hired', 'hire_requested' + $contactStatuses = ['shortlisted', 'contacted', 'interview_scheduled', 'selected', 'hired', 'hire_requested']; if (in_array($dbStatus, $contactStatuses)) { if (!User::canContactWorker($employer->id, $application->worker_id)) { $activeSub = \App\Models\Subscription::where('user_id', $employer->id)->where('status', 'active')->first(); @@ -638,26 +739,7 @@ public function employerUpdateApplicationStatus(Request $request, $id) } } - // If hired, also update worker status - if ($dbStatus === 'hired') { - if ($application->worker) { - $application->worker->update(['status' => 'Hired']); - } - // Create/update the JobOffer record to satisfy the Worker Employer List API - \App\Models\JobOffer::updateOrCreate( - [ - 'employer_id' => $employer->id, - 'worker_id' => $application->worker_id, - ], - [ - 'work_date' => $application->jobPost->start_date ?? now(), - 'location' => $application->jobPost->location ?? 'Dubai', - 'salary' => $application->jobPost->salary ?? 0, - 'status' => 'accepted', - 'notes' => $application->notes ?? 'Hired through Job Application', - ] - ); - } elseif ($dbStatus === 'rejected') { + if ($dbStatus === 'rejected') { if ($application->worker) { $application->worker->update(['status' => 'active']); } @@ -756,7 +838,164 @@ public function withdrawApplication(Request $request, $id) } /** - * Dispatch FCM Push Notification. + * API for workers to confirm a hire request. + * POST /api/workers/applied-jobs/{id}/confirm-hire + */ + public function confirmHire(Request $request, $id) + { + /** @var Worker $worker */ + $worker = $request->attributes->get('worker'); + + $application = JobApplication::where('worker_id', $worker->id)->find($id); + + if (!$application) { + return response()->json([ + 'success' => false, + 'message' => 'Application not found.' + ], 404); + } + + if (strtolower($application->status) !== 'hire_requested') { + return response()->json([ + 'success' => false, + 'message' => 'This application is not pending confirmation.' + ], 400); + } + + // Update application status + $history = $application->status_history ?: []; + $history[] = [ + 'status' => 'hired', + 'timestamp' => now()->toIso8601String(), + 'notes' => 'Hiring confirmed by worker.', + ]; + + $application->update([ + 'status' => 'hired', + 'status_history' => $history, + 'joining_confirmed_at' => now(), + ]); + + // Update worker status + $worker->update([ + 'status' => 'Hired', + 'availability' => 'Hired' + ]); + + // Create/update the JobOffer record to satisfy the Worker Employer List API + $job = $application->jobPost; + $employer = $job ? $job->employer : null; + if ($employer) { + \App\Models\JobOffer::updateOrCreate( + [ + 'employer_id' => $employer->id, + 'worker_id' => $worker->id, + ], + [ + 'work_date' => $job->start_date ?? now(), + 'location' => $job->location ?? 'Dubai', + 'salary' => $job->salary ?? 0, + 'status' => 'accepted', + 'notes' => $application->notes ?? 'Hired through Job Application', + ] + ); + + // Notify Employer: Hire confirmation + $employer->notify(new \App\Notifications\GenericNotification( + "Hire Confirmation", + "Worker " . ($worker->name ?? "Candidate") . " has confirmed the hiring for '" . ($job->title ?? 'Job Post') . "'.", + [ + 'type' => 'hire_confirmation', + 'job_id' => $job->id, + 'application_id' => $application->id, + ] + )); + } + + // Notify Worker: Hired + $worker->notify(new \App\Notifications\GenericNotification( + "Congratulations! You've been Hired", + "You are now officially hired for: '" . ($job->title ?? 'Job Post') . "'.", + [ + 'type' => 'hired', + 'job_id' => $job->id, + 'application_id' => $application->id, + ] + )); + + return response()->json([ + 'success' => true, + 'message' => 'Hiring confirmed successfully.', + 'data' => [ + 'application' => $application->fresh() + ] + ]); + } + + /** + * API for workers to decline a hire request. + * POST /api/workers/applied-jobs/{id}/decline-hire + */ + public function declineHire(Request $request, $id) + { + /** @var Worker $worker */ + $worker = $request->attributes->get('worker'); + + $application = JobApplication::where('worker_id', $worker->id)->find($id); + + if (!$application) { + return response()->json([ + 'success' => false, + 'message' => 'Application not found.' + ], 404); + } + + if (strtolower($application->status) !== 'hire_requested') { + return response()->json([ + 'success' => false, + 'message' => 'This application is not pending confirmation.' + ], 400); + } + + // Update application status back to declined + $history = $application->status_history ?: []; + $history[] = [ + 'status' => 'declined', + 'timestamp' => now()->toIso8601String(), + 'notes' => 'Hiring declined by worker.', + ]; + + $application->update([ + 'status' => 'declined', + 'status_history' => $history, + ]); + + $job = $application->jobPost; + $employer = $job ? $job->employer : null; + if ($employer) { + // Notify Employer: Hire declined + $employer->notify(new \App\Notifications\GenericNotification( + "Hire Request Declined", + "Worker " . ($worker->name ?? "Candidate") . " has declined the hiring request for '" . ($job->title ?? 'Job Post') . "'.", + [ + 'type' => 'hire_declined', + 'job_id' => $job->id, + 'application_id' => $application->id, + ] + )); + } + + return response()->json([ + 'success' => true, + 'message' => 'Hiring request declined successfully.', + 'data' => [ + 'application' => $application->fresh() + ] + ]); + } + + /** + * Dispatch FCM Push Notification & In-app database notifications. */ private function triggerStatusNotification($application, $status) { @@ -766,61 +1005,76 @@ private function triggerStatusNotification($application, $status) // When a new application is submitted: notify employer if ($status === 'applied') { - if ($employer && $employer->fcm_token) { - \App\Services\FCMService::sendPushNotification( - $employer->fcm_token, + if ($employer) { + $employer->notify(new \App\Notifications\GenericNotification( "New Job Application", "A candidate has applied for your job: " . ($job->title ?? 'Job Post'), [ - 'type' => 'new_job_application', + 'type' => 'job_application_submitted', 'job_id' => $job->id, 'application_id' => $application->id, ] - ); + )); } return; } // When status is updated: notify worker - if ($worker && $worker->fcm_token) { + if ($worker) { $title = "Job Application Update"; $body = ""; + $type = 'job_application_status_update'; switch (strtolower($status)) { case 'shortlisted': $body = "You have been shortlisted for the job: " . ($job->title ?? 'Job Post'); + $type = 'shortlisted'; + break; + case 'review': + case 'reviewing': + $body = "Your application is under review for the job: " . ($job->title ?? 'Job Post'); + $type = 'review'; break; case 'contacted': $body = "An employer wants to contact you regarding the job: " . ($job->title ?? 'Job Post'); + $type = 'contacted'; break; case 'interview scheduled': case 'interview_scheduled': $body = "An interview has been scheduled for the job: " . ($job->title ?? 'Job Post'); + $type = 'interview_scheduled'; break; case 'selected': $body = "Congratulations! You have been selected for the job: " . ($job->title ?? 'Job Post'); + $type = 'selected'; break; case 'rejected': $body = "Your application for the job: " . ($job->title ?? 'Job Post') . " has been reviewed and rejected."; + $type = 'rejected'; + break; + case 'hire_requested': + $title = "Action Required: Confirm Hiring"; + $body = "An employer wants to hire you for the job: " . ($job->title ?? 'Job Post') . ". Please confirm."; + $type = 'hire_request'; break; case 'hired': $body = "Congratulations! You have been hired for the job: " . ($job->title ?? 'Job Post'); + $type = 'hired'; break; default: $body = "Your application status for the job: " . ($job->title ?? 'Job Post') . " has changed to " . ucfirst($status); break; } - \App\Services\FCMService::sendPushNotification( - $worker->fcm_token, + $worker->notify(new \App\Notifications\GenericNotification( $title, $body, [ - 'type' => 'job_application_status_update', + 'type' => $type, 'job_id' => $job->id ?? '', 'application_id' => $application->id, 'status' => $status, ] - ); + )); } } } diff --git a/app/Http/Controllers/Api/WorkerProfileController.php b/app/Http/Controllers/Api/WorkerProfileController.php index e339ea2..80bd1d5 100644 --- a/app/Http/Controllers/Api/WorkerProfileController.php +++ b/app/Http/Controllers/Api/WorkerProfileController.php @@ -78,7 +78,7 @@ public function updateProfile(Request $request) 'skills' => 'nullable|array', 'skills.*' => 'exists:skills,id', 'in_country' => 'nullable', - 'visa_status' => 'nullable|string|in:Tourist Visa,Employment Visa,Residence Visa', + 'visa_status' => 'nullable|string', 'preferred_job_type' => 'nullable|string|max:100', 'gender' => 'nullable|string|in:male,female,other', 'live_in_out' => 'nullable|string|in:live_in,live_out', @@ -472,10 +472,9 @@ public function respondToOffer(Request $request, $id) // Dispatch push notification to employer $employer = $offer->employer; - if ($employer && $employer->fcm_token) { + if ($employer) { $statusMessage = $responseStatus === 'accepted' ? 'accepted' : 'rejected'; - \App\Services\FCMService::sendPushNotification( - $employer->fcm_token, + $employer->notify(new \App\Notifications\GenericNotification( "Job Offer " . ucfirst($statusMessage), "Worker " . ($worker->name ?? "Candidate") . " has " . $statusMessage . " your job offer.", [ @@ -483,7 +482,7 @@ public function respondToOffer(Request $request, $id) 'offer_id' => $offer->id, 'status' => $responseStatus, ] - ); + )); } return response()->json([ diff --git a/app/Http/Controllers/Employer/CandidateController.php b/app/Http/Controllers/Employer/CandidateController.php index 59d41d2..37a0ede 100644 --- a/app/Http/Controllers/Employer/CandidateController.php +++ b/app/Http/Controllers/Employer/CandidateController.php @@ -316,7 +316,7 @@ private function checkJobAccess($user) public function updateStatus(Request $request, $id) { $request->validate([ - 'status' => 'required|string|in:Applied,Reviewing,Shortlisted,Contacted,Interview Scheduled,Selected,Rejected,Hired,Offer Sent,applied,shortlisted,contacted,interview scheduled,interview_scheduled,selected,rejected,hired,offer sent,offer_sent', + 'status' => 'required|string|in:Applied,Reviewing,Shortlisted,Contacted,Interview Scheduled,Selected,Rejected,Hired,Offer Sent,applied,shortlisted,contacted,interview scheduled,interview_scheduled,selected,rejected,hired,offer sent,offer_sent,hire_requested,hire_requested', 'notes' => 'nullable|string', ]); @@ -327,18 +327,31 @@ public function updateStatus(Request $request, $id) $dbStatus = 'pending'; if (in_array(strtolower($request->status), ['hired', 'accepted'])) { - $dbStatus = 'accepted'; - $offer->worker->update(['status' => 'Hired']); + // Under two-way confirmation, it must NOT immediately set status to accepted/Hired. + // It should set offer status to 'pending' (waiting for worker confirmation). + $dbStatus = 'pending'; } elseif (in_array(strtolower($request->status), ['rejected'])) { $dbStatus = 'rejected'; - $offer->worker->update(['status' => 'active']); } elseif (in_array(strtolower($request->status), ['offer sent', 'offer_sent', 'pending'])) { $dbStatus = 'pending'; } $offer->update(['status' => $dbStatus]); - return back()->with('success', 'Direct candidate hiring offer status updated successfully to ' . $request->status); + // Notify worker about the hire offer if it is set to pending + if ($dbStatus === 'pending') { + $offer->worker->notify(new \App\Notifications\GenericNotification( + "Action Required: Confirm Hiring", + "Employer " . ($offer->employer->name ?? "Employer") . " has sent you a direct hire offer. Please confirm.", + [ + 'type' => 'hire_request', + 'offer_id' => $offer->id, + 'status' => 'pending', + ] + )); + } + + return back()->with('success', 'Hire request initiated successfully. Awaiting candidate confirmation.'); } // Standard job application status update @@ -356,8 +369,8 @@ public function updateStatus(Request $request, $id) $dbStatus = 'applied'; $statusLower = strtolower($request->status); - if ($statusLower === 'hired') { - $dbStatus = 'hired'; + if ($statusLower === 'hired' || $statusLower === 'hire_requested') { + $dbStatus = 'hire_requested'; } elseif ($statusLower === 'rejected') { $dbStatus = 'rejected'; } elseif ($statusLower === 'shortlisted' || $statusLower === 'offer sent' || $statusLower === 'offer_sent') { @@ -372,8 +385,8 @@ public function updateStatus(Request $request, $id) $dbStatus = 'applied'; } - // Contact Limit check: if moving to 'shortlisted', 'contacted', 'interview_scheduled', 'selected', 'hired' - $contactStatuses = ['shortlisted', 'contacted', 'interview_scheduled', 'selected', 'hired']; + // Contact Limit check: if moving to 'shortlisted', 'contacted', 'interview_scheduled', 'selected', 'hired', 'hire_requested' + $contactStatuses = ['shortlisted', 'contacted', 'interview_scheduled', 'selected', 'hired', 'hire_requested']; if (in_array($dbStatus, $contactStatuses)) { if (!User::canContactWorker($user->id, $app->worker_id)) { $activeSub = \App\Models\Subscription::where('user_id', $user->id)->where('status', 'active')->first(); @@ -419,26 +432,7 @@ public function updateStatus(Request $request, $id) } } - // If hired, also update worker status - if ($dbStatus === 'hired') { - if ($app->worker) { - $app->worker->update(['status' => 'Hired']); - } - // Create/update the JobOffer record to satisfy the Worker Employer List API - JobOffer::updateOrCreate( - [ - 'employer_id' => $user->id, - 'worker_id' => $app->worker_id, - ], - [ - 'work_date' => $app->jobPost->start_date ?? now(), - 'location' => $app->jobPost->location ?? 'Dubai', - 'salary' => $app->jobPost->salary ?? 0, - 'status' => 'accepted', - 'notes' => $app->notes ?? 'Hired through Job Application', - ] - ); - } elseif ($dbStatus === 'rejected') { + if ($dbStatus === 'rejected') { if ($app->worker) { $app->worker->update(['status' => 'active']); } @@ -458,61 +452,76 @@ private function triggerStatusNotification($application, $status) // When a new application is submitted: notify employer if ($status === 'applied') { - if ($employer && $employer->fcm_token) { - \App\Services\FCMService::sendPushNotification( - $employer->fcm_token, + if ($employer) { + $employer->notify(new \App\Notifications\GenericNotification( "New Job Application", "A candidate has applied for your job: " . ($job->title ?? 'Job Post'), [ - 'type' => 'new_job_application', + 'type' => 'job_application_submitted', 'job_id' => $job->id, 'application_id' => $application->id, ] - ); + )); } return; } // When status is updated: notify worker - if ($worker && $worker->fcm_token) { + if ($worker) { $title = "Job Application Update"; $body = ""; + $type = 'job_application_status_update'; switch (strtolower($status)) { case 'shortlisted': $body = "You have been shortlisted for the job: " . ($job->title ?? 'Job Post'); + $type = 'shortlisted'; + break; + case 'review': + case 'reviewing': + $body = "Your application is under review for the job: " . ($job->title ?? 'Job Post'); + $type = 'review'; break; case 'contacted': $body = "An employer wants to contact you regarding the job: " . ($job->title ?? 'Job Post'); + $type = 'contacted'; break; case 'interview scheduled': case 'interview_scheduled': $body = "An interview has been scheduled for the job: " . ($job->title ?? 'Job Post'); + $type = 'interview_scheduled'; break; case 'selected': $body = "Congratulations! You have been selected for the job: " . ($job->title ?? 'Job Post'); + $type = 'selected'; break; case 'rejected': $body = "Your application for the job: " . ($job->title ?? 'Job Post') . " has been reviewed and rejected."; + $type = 'rejected'; + break; + case 'hire_requested': + $title = "Action Required: Confirm Hiring"; + $body = "An employer wants to hire you for the job: " . ($job->title ?? 'Job Post') . ". Please confirm."; + $type = 'hire_request'; break; case 'hired': $body = "Congratulations! You have been hired for the job: " . ($job->title ?? 'Job Post'); + $type = 'hired'; break; default: $body = "Your application status for the job: " . ($job->title ?? 'Job Post') . " has changed to " . ucfirst($status); break; } - \App\Services\FCMService::sendPushNotification( - $worker->fcm_token, + $worker->notify(new \App\Notifications\GenericNotification( $title, $body, [ - 'type' => 'job_application_status_update', + 'type' => $type, 'job_id' => $job->id ?? '', 'application_id' => $application->id, 'status' => $status, ] - ); + )); } } } diff --git a/app/Http/Controllers/Employer/JobController.php b/app/Http/Controllers/Employer/JobController.php index 1562691..1d73de4 100644 --- a/app/Http/Controllers/Employer/JobController.php +++ b/app/Http/Controllers/Employer/JobController.php @@ -139,7 +139,32 @@ public function create() ->with('error', 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.'); } - return Inertia::render('Employer/Jobs/Create'); + $professions = \App\Models\Skill::orderBy('name') + ->pluck('name') + ->map(function ($name) { + return ucwords($name); + }) + ->unique() + ->values() + ->toArray(); + + if (empty($professions)) { + $professions = [ + 'Housekeeper', 'Nanny', 'Maid', 'Electrician', 'Mason', 'Plumber', 'Cleaner', 'Driver', 'Caregiver', 'Cook' + ]; + } + + $skills = \App\Models\Skill::all()->map(function ($skill) { + return [ + 'id' => $skill->id, + 'name' => $skill->name, + ]; + }); + + return Inertia::render('Employer/Jobs/Create', [ + 'professions' => $professions, + 'availableSkills' => $skills, + ]); } public function store(Request $request) @@ -156,6 +181,8 @@ public function store(Request $request) $request->validate([ 'title' => 'required|string|max:255', + 'main_profession' => 'required|string|max:255', + 'skills' => 'required', 'workers_needed' => 'required|integer|min:1', 'location' => 'required|string|max:255', 'salary' => 'required|numeric|min:0', @@ -165,9 +192,10 @@ public function store(Request $request) 'requirements' => 'nullable|string', ]); - JobPost::create([ + $job = JobPost::create([ 'employer_id' => $user->id, 'title' => $request->title, + 'main_profession' => $request->main_profession, 'workers_needed' => $request->workers_needed, 'job_type' => $request->job_type, 'location' => $request->location, @@ -178,6 +206,27 @@ public function store(Request $request) 'status' => 'active', ]); + // Sync skills + $skillsInput = $request->skills; + $skillIds = []; + if (is_array($skillsInput)) { + foreach ($skillsInput as $skillNameOrId) { + if (is_numeric($skillNameOrId)) { + $skillIds[] = (int)$skillNameOrId; + } else { + $skill = \App\Models\Skill::firstOrCreate(['name' => trim($skillNameOrId)]); + $skillIds[] = $skill->id; + } + } + } else if (is_string($skillsInput)) { + $skillsArray = array_filter(array_map('trim', explode(',', $skillsInput))); + foreach ($skillsArray as $name) { + $skill = \App\Models\Skill::firstOrCreate(['name' => $name]); + $skillIds[] = $skill->id; + } + } + $job->skills()->sync($skillIds); + return redirect()->route('employer.jobs')->with('success', 'Job posted successfully.'); } @@ -242,7 +291,7 @@ public function edit($id) ->with('error', 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.'); } - $job = JobPost::where('employer_id', $user->id)->where('id', $id)->firstOrFail(); + $job = JobPost::where('employer_id', $user->id)->where('id', $id)->with('skills')->firstOrFail(); // Convert start_date format to Y-m-d for date input $jobData = $job->toArray(); @@ -250,8 +299,40 @@ public function edit($id) $jobData['start_date'] = $job->start_date->format('Y-m-d'); } + // Add skills list + $jobData['skills'] = $job->skills->map(function ($s) { + return [ + 'id' => $s->id, + 'name' => $s->name, + ]; + })->toArray(); + + $professions = \App\Models\Skill::orderBy('name') + ->pluck('name') + ->map(function ($name) { + return ucwords($name); + }) + ->unique() + ->values() + ->toArray(); + + if (empty($professions)) { + $professions = [ + 'Housekeeper', 'Nanny', 'Maid', 'Electrician', 'Mason', 'Plumber', 'Cleaner', 'Driver', 'Caregiver', 'Cook' + ]; + } + + $skills = \App\Models\Skill::all()->map(function ($skill) { + return [ + 'id' => $skill->id, + 'name' => $skill->name, + ]; + }); + return Inertia::render('Employer/Jobs/Edit', [ 'job' => $jobData, + 'professions' => $professions, + 'availableSkills' => $skills, ]); } @@ -269,8 +350,14 @@ public function update(Request $request, $id) $job = JobPost::where('employer_id', $user->id)->where('id', $id)->firstOrFail(); + if ($job->applications()->exists()) { + return back()->withErrors(['general' => 'This job cannot be edited because workers have already applied for it.']); + } + $request->validate([ 'title' => 'required|string|max:255', + 'main_profession' => 'required|string|max:255', + 'skills' => 'required', 'workers_needed' => 'required|integer|min:1', 'location' => 'required|string|max:255', 'salary' => 'required|numeric|min:0', @@ -283,6 +370,7 @@ public function update(Request $request, $id) $job->update([ 'title' => $request->title, + 'main_profession' => $request->main_profession, 'workers_needed' => $request->workers_needed, 'job_type' => $request->job_type, 'location' => $request->location, @@ -293,6 +381,27 @@ public function update(Request $request, $id) 'status' => strtolower($request->status), ]); + // Sync skills + $skillsInput = $request->skills; + $skillIds = []; + if (is_array($skillsInput)) { + foreach ($skillsInput as $skillNameOrId) { + if (is_numeric($skillNameOrId)) { + $skillIds[] = (int)$skillNameOrId; + } else { + $skill = \App\Models\Skill::firstOrCreate(['name' => trim($skillNameOrId)]); + $skillIds[] = $skill->id; + } + } + } else if (is_string($skillsInput)) { + $skillsArray = array_filter(array_map('trim', explode(',', $skillsInput))); + foreach ($skillsArray as $name) { + $skill = \App\Models\Skill::firstOrCreate(['name' => $name]); + $skillIds[] = $skill->id; + } + } + $job->skills()->sync($skillIds); + return redirect()->route('employer.jobs')->with('success', 'Job updated successfully.'); } diff --git a/app/Http/Controllers/Employer/MessageController.php b/app/Http/Controllers/Employer/MessageController.php index 88079b2..2c37799 100644 --- a/app/Http/Controllers/Employer/MessageController.php +++ b/app/Http/Controllers/Employer/MessageController.php @@ -37,6 +37,21 @@ private function resolveCurrentUser() return null; } + private function getWorkerStatus($employerId, $workerId, $defaultStatus) + { + $pendingOfferOrApp = JobOffer::where('employer_id', $employerId) + ->where('worker_id', $workerId) + ->where('status', 'pending') + ->exists() || + \App\Models\JobApplication::where('worker_id', $workerId) + ->whereHas('jobPost', function($q) use ($employerId) { + $q->where('employer_id', $employerId); + })->where('status', 'hire_requested') + ->exists(); + + return $pendingOfferOrApp ? 'pending_confirmation' : strtolower($defaultStatus ?? 'active'); + } + public static function processWorkerResponse($conv, $worker, $text) { $replyText = strtolower(trim($text)); @@ -105,13 +120,13 @@ public function index(Request $request) ->latest('updated_at') ->get(); - $conversations = $dbConversations->map(function ($conv) { + $conversations = $dbConversations->map(function ($conv) use ($user) { $lastMsg = $conv->messages->last(); return [ 'id' => $conv->id, 'worker_id' => $conv->worker_id, 'worker_name' => $conv->worker->name ?? 'Candidate', - 'worker_status' => strtolower($conv->worker->status ?? 'active'), + 'worker_status' => $this->getWorkerStatus($user->id, $conv->worker_id, $conv->worker->status), 'last_message' => $lastMsg->text ?? 'No messages yet.', 'unread' => $lastMsg ? ($lastMsg->sender_type === 'worker' && is_null($lastMsg->read_at)) : false, 'online' => true, @@ -136,13 +151,13 @@ public function show($id) ->latest('updated_at') ->get(); - $conversations = $dbConversations->map(function ($conv) { + $conversations = $dbConversations->map(function ($conv) use ($user) { $lastMsg = $conv->messages->last(); return [ 'id' => $conv->id, 'worker_id' => $conv->worker_id, 'worker_name' => $conv->worker->name ?? 'Candidate', - 'worker_status' => strtolower($conv->worker->status ?? 'active'), + 'worker_status' => $this->getWorkerStatus($user->id, $conv->worker_id, $conv->worker->status), 'last_message' => $lastMsg->text ?? 'No messages yet.', 'unread' => $lastMsg ? ($lastMsg->sender_type === 'worker' && is_null($lastMsg->read_at)) : false, 'online' => true, @@ -165,7 +180,7 @@ public function show($id) 'id' => $activeConv->id, 'worker_id' => $activeConv->worker_id, 'worker_name' => $activeConv->worker->name ?? 'Candidate', - 'worker_status' => strtolower($activeConv->worker->status ?? 'active'), + 'worker_status' => $this->getWorkerStatus($user->id, $activeConv->worker_id, $activeConv->worker->status), 'online' => true, 'salary' => ($activeConv->worker->salary ?? 2000) . ' AED', 'nationality' => $activeConv->worker->nationality ?? 'Unknown', diff --git a/app/Http/Controllers/Employer/WorkerController.php b/app/Http/Controllers/Employer/WorkerController.php index 4b13faa..e2c08a0 100644 --- a/app/Http/Controllers/Employer/WorkerController.php +++ b/app/Http/Controllers/Employer/WorkerController.php @@ -365,6 +365,16 @@ public function show($id) $visaStatus = $w->visa_status; + $pendingOfferOrApp = JobOffer::where('employer_id', $user ? $user->id : 0) + ->where('worker_id', $w->id) + ->where('status', 'pending') + ->exists() || + \App\Models\JobApplication::where('worker_id', $w->id) + ->whereHas('jobPost', function($q) use ($user) { + $q->where('employer_id', $user ? $user->id : 0); + })->where('status', 'hire_requested') + ->exists(); + $worker = [ 'id' => $w->id, 'name' => $w->name, @@ -392,7 +402,7 @@ public function show($id) 'reviews' => $reviews, 'similar_workers' => $similarWorkers, 'status' => $w->status, - 'availability_status' => $w->status, + 'availability_status' => $pendingOfferOrApp ? 'Pending Confirmation' : ($w->status === 'Hired' ? 'Hired' : $w->status), 'document_expiry_status' => $w->document_expiry_status, 'document_expiry_days' => $w->document_expiry_days, 'visa_expiry_date' => $w->visa_expiry_date, @@ -479,9 +489,8 @@ public function markHired(Request $request, $id) } $worker = Worker::findOrFail($id); - $worker->update([ - 'status' => 'Hired', - ]); + + // Do NOT update worker status to 'Hired' directly. // Check if there is an existing job offer for this employer and worker $offer = JobOffer::where('employer_id', $employerId) @@ -489,7 +498,7 @@ public function markHired(Request $request, $id) ->first(); if ($offer) { - $offer->update(['status' => 'accepted']); + $offer->update(['status' => 'pending']); } else { // Also check if there's an existing job application $jobIds = \App\Models\JobPost::where('employer_id', $employerId)->pluck('id'); @@ -498,21 +507,41 @@ public function markHired(Request $request, $id) ->first(); if ($application) { - $application->update(['status' => 'hired']); + $application->update(['status' => 'hire_requested']); + + $history = $application->status_history ?: []; + $history[] = [ + 'status' => 'hire_requested', + 'timestamp' => now()->toIso8601String(), + 'notes' => 'Hire request initiated by employer.', + ]; + $application->update(['status_history' => $history]); } else { - // Create a new direct job offer with status accepted - JobOffer::create([ + // Create a new direct job offer with status pending + $offer = 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', + 'notes' => 'Direct sponsoring matching initiated.', + 'status' => 'pending', ]); } } - return back()->with('success', 'Worker successfully marked as Hired!'); + // Notify the worker + $worker->notify(new \App\Notifications\GenericNotification( + "Action Required: Confirm Hiring", + "Employer " . ($user->name ?? "Employer") . " has sent you a hire request. Please confirm.", + [ + 'type' => 'hire_request', + 'offer_id' => $offer->id ?? '', + 'application_id' => $application->id ?? '', + 'status' => $offer ? 'pending' : 'hire_requested', + ] + )); + + return back()->with('success', 'Hire request initiated successfully! Awaiting candidate confirmation.'); } } diff --git a/app/Models/JobPost.php b/app/Models/JobPost.php index 444e467..c4efafe 100644 --- a/app/Models/JobPost.php +++ b/app/Models/JobPost.php @@ -13,6 +13,7 @@ class JobPost extends Model protected $fillable = [ 'employer_id', 'title', + 'main_profession', 'workers_needed', 'job_type', 'location', @@ -34,6 +35,10 @@ public function employer() return $this->belongsTo(User::class, 'employer_id'); } + public function skills() + { + return $this->belongsToMany(Skill::class, 'job_post_skills', 'job_post_id', 'skill_id'); + } public function applications() { diff --git a/app/Notifications/GenericNotification.php b/app/Notifications/GenericNotification.php new file mode 100644 index 0000000..dc30a30 --- /dev/null +++ b/app/Notifications/GenericNotification.php @@ -0,0 +1,65 @@ +title = $title; + $this->body = $body; + $this->data = $data; + } + + /** + * Get the notification's delivery channels. + */ + public function via($notifiable): array + { + $channels = ['database']; + if (!empty($notifiable->fcm_token)) { + $channels[] = FcmChannel::class; + } + return $channels; + } + + /** + * Get the array representation of the notification for in-app database storage. + */ + public function toArray($notifiable): array + { + return array_merge([ + 'title' => $this->title, + 'body' => $this->body, + ], $this->data); + } + + /** + * Get the push notification representation. + */ + public function toFcm($notifiable): array + { + return [ + 'token' => $notifiable->fcm_token, + 'title' => $this->title, + 'body' => $this->body, + 'data' => array_merge([ + 'title' => $this->title, + 'body' => $this->body, + ], $this->data), + ]; + } +} diff --git a/database/migrations/2026_07_06_091221_add_main_profession_and_skills_to_job_posts_table.php b/database/migrations/2026_07_06_091221_add_main_profession_and_skills_to_job_posts_table.php new file mode 100644 index 0000000..263822e --- /dev/null +++ b/database/migrations/2026_07_06_091221_add_main_profession_and_skills_to_job_posts_table.php @@ -0,0 +1,37 @@ +string('main_profession')->nullable()->after('title'); + }); + + Schema::create('job_post_skills', function (Blueprint $table) { + $table->id(); + $table->foreignId('job_post_id')->constrained('job_posts')->onDelete('cascade'); + $table->foreignId('skill_id')->constrained('skills')->onDelete('cascade'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('job_post_skills'); + + Schema::table('job_posts', function (Blueprint $table) { + $table->dropColumn('main_profession'); + }); + } +}; diff --git a/resources/js/Pages/Employer/Jobs/Applicants.jsx b/resources/js/Pages/Employer/Jobs/Applicants.jsx index 9c35c79..f2e69d0 100644 --- a/resources/js/Pages/Employer/Jobs/Applicants.jsx +++ b/resources/js/Pages/Employer/Jobs/Applicants.jsx @@ -193,10 +193,11 @@ export default function Applicants({ job, applicants: initialApplicants, isShort
openStatusModal(applicant)}> - {applicant.status} + {applicant.status?.toLowerCase() === 'hire_requested' ? 'Pending Confirmation' : applicant.status}
@@ -261,6 +262,11 @@ export default function Applicants({ job, applicants: initialApplicants, isShort {/* Modal Body */}
+ {selectedApp?.status?.toLowerCase() === 'hire_requested' && ( +
+ A hiring offer is pending. Waiting for worker confirmation. +
+ )} {/* Status Selector */}
@@ -275,7 +281,7 @@ export default function Applicants({ job, applicants: initialApplicants, isShort - +
diff --git a/resources/js/Pages/Employer/Jobs/Create.jsx b/resources/js/Pages/Employer/Jobs/Create.jsx index 729f402..c29eac9 100644 --- a/resources/js/Pages/Employer/Jobs/Create.jsx +++ b/resources/js/Pages/Employer/Jobs/Create.jsx @@ -7,29 +7,31 @@ import { Briefcase, MapPin, DollarSign, - Calendar, LayoutGrid, Users, FileText, Sparkles, - ShieldCheck + ShieldCheck, + X } from 'lucide-react'; import { toast } from 'sonner'; -export default function Create({ categories: initialCategories }) { - const categories = initialCategories || [ - 'Electrician', 'Mason', 'Plumber', 'Cleaner', 'Site Supervisor', 'Driver', 'General Helper' +export default function Create({ professions, availableSkills }) { + const categoryList = professions || [ + 'Housekeeper', 'Nanny', 'Maid', 'Electrician', 'Mason', 'Plumber', 'Cleaner', 'Driver', 'Caregiver', 'Cook' ]; + const skillList = availableSkills || []; const { data, setData, post, processing, errors } = useForm({ title: '', + main_profession: '', + skills: [], workers_needed: 1, location: '', salary: '', job_type: 'Full Time', - start_date: '', + start_date: new Date().toISOString().split('T')[0], description: '', - requirements: '' }); const [clientErrors, setClientErrors] = useState({}); @@ -42,6 +44,12 @@ export default function Create({ categories: initialCategories }) { if (!data.title.trim()) { newErrors.title = 'Job title is required.'; } + if (!data.main_profession) { + newErrors.main_profession = 'Main profession is required.'; + } + if (!data.skills || data.skills.length === 0) { + newErrors.skills = 'At least one skill is required.'; + } if (!data.workers_needed || data.workers_needed < 1) { newErrors.workers_needed = 'Workers needed must be at least 1.'; } @@ -133,7 +141,29 @@ export default function Create({ categories: initialCategories }) { )}
-
+
+ +
+ + +
+ {(clientErrors.main_profession || errors.main_profession) && ( +

{clientErrors.main_profession || errors.main_profession}

+ )} +
+ +
@@ -152,37 +182,7 @@ export default function Create({ categories: initialCategories }) { )}
-
- -
- {['Full Time', 'Part Time', 'Contract'].map(type => ( - - ))} -
-
-
-
- - {/* Section 2: Logistics & Pay */} -
-

-
- Logistics & Compensation -

- -
-
+
@@ -201,7 +201,7 @@ export default function Create({ categories: initialCategories }) { )}
-
+
@@ -220,27 +220,29 @@ export default function Create({ categories: initialCategories }) { )}
-
- -
- - setData('start_date', e.target.value)} - /> +
+ +
+ {['Full Time', 'Part Time'].map(type => ( + + ))}
- {(clientErrors.start_date || errors.start_date) && ( -

{clientErrors.start_date || errors.start_date}

- )}
- {/* Section 3: Details */} + {/* Section 2: Detailed Requirements */}

@@ -248,6 +250,56 @@ export default function Create({ categories: initialCategories }) {

+
+ +
+
+ + +
+ + {/* Selected skills pills */} + {data.skills.length > 0 && ( +
+ {data.skills.map(skill => ( + + {skill} + + + ))} +
+ )} +
+ {(clientErrors.skills || errors.skills) && ( +

{clientErrors.skills || errors.skills}

+ )} +
+