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
{clientErrors.main_profession || errors.main_profession}
+ )} +{clientErrors.start_date || errors.start_date}
- )}{clientErrors.skills || errors.skills}
+ )} +{clientErrors.main_profession || errors.main_profession}
+ )} +{clientErrors.location || errors.location}
+ )} +{clientErrors.salary || errors.salary}
+ )} +{clientErrors.location || errors.location}
- )} -{clientErrors.salary || errors.salary}
- )} -{clientErrors.start_date || errors.start_date}
- )} -{clientErrors.skills || errors.skills}
+ )} +