job sent for worker job list

This commit is contained in:
mohanmd 2026-07-06 15:05:22 +05:30
parent e475a7558b
commit 88e4f3fdb4
21 changed files with 1198 additions and 368 deletions

View File

@ -132,7 +132,7 @@ public function updateProfile(Request $request, $id)
'experience' => 'required|string', 'experience' => 'required|string',
'salary' => 'nullable|numeric', 'salary' => 'nullable|numeric',
'nationality' => 'nullable|string', 'nationality' => 'nullable|string',
'visa_status' => 'nullable|string|in:Tourist Visa,Employment Visa,Residence Visa', 'visa_status' => 'nullable|string',
'age' => 'nullable|integer', 'age' => 'nullable|integer',
'in_country' => 'nullable', 'in_country' => 'nullable',
'preferred_job_type' => 'nullable|string', 'preferred_job_type' => 'nullable|string',

View File

@ -20,7 +20,7 @@ class EmployerWorkerController extends Controller
/** /**
* Helper to map worker DB models to API presentation format. * 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 // Map languages from database comma-separated string
$langs = $w->language ? array_map('trim', explode(',', $w->language)) : ['English']; $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); $offerId = substr($targetId, 6);
$offer = JobOffer::where('id', $offerId)->where('employer_id', $employerId)->firstOrFail(); $offer = JobOffer::where('id', $offerId)->where('employer_id', $employerId)->firstOrFail();
$offer->update(['status' => 'accepted']); $offer->update(['status' => 'pending']);
if ($offer->worker) { $worker = $offer->worker;
$offer->worker->update(['status' => 'Hired']);
$worker = $offer->worker;
}
$updatedOffer = $offer; $updatedOffer = $offer;
} else { } else {
// Check if targetId is an application // Check if targetId is an application
@ -791,26 +788,9 @@ public function hireCandidate(Request $request, $id = null)
})->first(); })->first();
if ($application) { if ($application) {
$application->update(['status' => 'hired']); $application->update(['status' => 'hire_requested']);
if ($application->worker) { $worker = $application->worker;
$application->worker->update(['status' => 'Hired']);
$worker = $application->worker;
}
$updatedApplication = $application; $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 { } else {
// Try targeting by Worker ID directly // Try targeting by Worker ID directly
$worker = Worker::find($targetId); $worker = Worker::find($targetId);
@ -822,15 +802,13 @@ public function hireCandidate(Request $request, $id = null)
], 404); ], 404);
} }
$worker->update(['status' => 'Hired']); // Find existing direct offer or create a new one with status 'pending'
// Find existing direct offer
$offer = JobOffer::where('employer_id', $employerId) $offer = JobOffer::where('employer_id', $employerId)
->where('worker_id', $worker->id) ->where('worker_id', $worker->id)
->first(); ->first();
if ($offer) { if ($offer) {
$offer->update(['status' => 'accepted']); $offer->update(['status' => 'pending']);
$updatedOffer = $offer; $updatedOffer = $offer;
} else { } else {
// Find existing application // Find existing application
@ -840,57 +818,57 @@ public function hireCandidate(Request $request, $id = null)
->first(); ->first();
if ($application) { if ($application) {
$application->update(['status' => 'hired']); $application->update(['status' => 'hire_requested']);
$updatedApplication = $application; $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 { } else {
// Create a new direct job offer and accept it // Create a new direct job offer in 'pending' status
$updatedOffer = JobOffer::create([ $updatedOffer = JobOffer::create([
'employer_id' => $employerId, 'employer_id' => $employerId,
'worker_id' => $worker->id, 'worker_id' => $worker->id,
'work_date' => now()->format('Y-m-d'), 'work_date' => now()->format('Y-m-d'),
'location' => 'Dubai', 'location' => 'Dubai',
'salary' => $worker->salary, 'salary' => $worker->salary,
'notes' => 'Direct sponsoring matching finalized via mobile API.', 'notes' => 'Direct sponsoring matching initiated via API.',
'status' => 'accepted', 'status' => 'pending',
]); ]);
} }
} }
} }
} }
// Dispatch push notification to worker // Dispatch push & database notification to worker for confirmation request
if ($worker && $worker->fcm_token) { if ($worker) {
\App\Services\FCMService::sendPushNotification( if ($updatedApplication) {
$worker->fcm_token, $job = $updatedApplication->jobPost;
"Congratulations! You've been Hired", $worker->notify(new \App\Notifications\GenericNotification(
"Employer " . ($employer->name ?? "Employer") . " has hired you.", "Action Required: Confirm Hiring",
[ "Employer " . ($employer->name ?? "Employer") . " wants to hire you for '" . ($job->title ?? 'Job Post') . "'. Please confirm.",
'type' => 'hired', [
'employer_id' => $employer->id, '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([ return response()->json([
'success' => true, 'success' => true,
'message' => 'Candidate successfully marked as Hired!', 'message' => 'Hire request initiated successfully. Awaiting candidate confirmation.',
'data' => [ 'data' => [
'worker_id' => $worker ? $worker->id : null, 'worker_id' => $worker ? $worker->id : null,
'worker_status' => $worker ? $worker->status : 'Hired', 'worker_status' => $worker ? $worker->status : 'Active',
'application' => $updatedApplication, 'application' => $updatedApplication,
'offer' => $updatedOffer, 'offer' => $updatedOffer,
] ]

View File

@ -318,7 +318,7 @@ public function register(Request $request)
'age' => 'nullable|integer', 'age' => 'nullable|integer',
'experience' => 'nullable|string|max:100', 'experience' => 'nullable|string|max:100',
'in_country' => 'nullable', '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', 'preferred_job_type' => 'nullable|string|max:100',
'main_profession' => 'nullable|string|max:100', 'main_profession' => 'nullable|string|max:100',
'gender' => 'nullable|string|in:male,female,other', 'gender' => 'nullable|string|in:male,female,other',

View File

@ -18,14 +18,38 @@ class WorkerJobController extends Controller
*/ */
public function listJobs(Request $request) public function listJobs(Request $request)
{ {
$jobs = JobPost::where('status', 'active') /** @var Worker $worker */
->with(['employer.employerProfile']) $worker = $request->attributes->get('worker');
->latest()
$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() ->get()
->map(function ($job) { ->map(function ($job) {
return [ return [
'id' => $job->id, 'id' => $job->id,
'title' => $job->title, 'title' => $job->title,
'main_profession' => $job->main_profession,
'skills' => $job->skills->pluck('name')->toArray(),
'location' => $job->location, 'location' => $job->location,
'salary' => (int) $job->salary, 'salary' => (int) $job->salary,
'workers_needed' => $job->workers_needed, 'workers_needed' => $job->workers_needed,
@ -54,7 +78,7 @@ public function listJobs(Request $request)
public function jobDetails($id) public function jobDetails($id)
{ {
$job = JobPost::where('status', 'active') $job = JobPost::where('status', 'active')
->with(['employer.employerProfile']) ->with(['employer.employerProfile', 'skills'])
->find($id); ->find($id);
if (!$job) { if (!$job) {
@ -70,6 +94,8 @@ public function jobDetails($id)
'job' => [ 'job' => [
'id' => $job->id, 'id' => $job->id,
'title' => $job->title, 'title' => $job->title,
'main_profession' => $job->main_profession,
'skills' => $job->skills->pluck('name')->toArray(),
'location' => $job->location, 'location' => $job->location,
'salary' => (int) $job->salary, 'salary' => (int) $job->salary,
'workers_needed' => $job->workers_needed, 'workers_needed' => $job->workers_needed,
@ -121,6 +147,19 @@ public function applyJob(Request $request, $id)
'status' => 'applied' '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([ return response()->json([
'success' => true, 'success' => true,
'message' => 'Successfully applied for the job.', 'message' => 'Successfully applied for the job.',
@ -244,6 +283,8 @@ public function employerCreateJob(Request $request)
$validator = Validator::make($request->all(), [ $validator = Validator::make($request->all(), [
'title' => 'required|string|max:255', 'title' => 'required|string|max:255',
'main_profession' => 'required|string|max:255',
'skills' => 'required',
'workers_needed' => 'required|integer|min:1', 'workers_needed' => 'required|integer|min:1',
'location' => 'required|string|max:255', 'location' => 'required|string|max:255',
'salary' => 'required|numeric|min:0', 'salary' => 'required|numeric|min:0',
@ -264,6 +305,7 @@ public function employerCreateJob(Request $request)
$job = JobPost::create([ $job = JobPost::create([
'employer_id' => $employer->id, 'employer_id' => $employer->id,
'title' => $request->title, 'title' => $request->title,
'main_profession' => $request->main_profession,
'workers_needed' => $request->workers_needed, 'workers_needed' => $request->workers_needed,
'job_type' => $request->job_type, 'job_type' => $request->job_type,
'location' => $request->location, 'location' => $request->location,
@ -274,6 +316,27 @@ public function employerCreateJob(Request $request)
'status' => 'active', '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([ return response()->json([
'success' => true, 'success' => true,
'message' => 'Job posted successfully.', 'message' => 'Job posted successfully.',
@ -281,6 +344,8 @@ public function employerCreateJob(Request $request)
'job' => [ 'job' => [
'id' => $job->id, 'id' => $job->id,
'title' => $job->title, 'title' => $job->title,
'main_profession' => $job->main_profession,
'skills' => $job->skills()->pluck('name')->toArray(),
'location' => $job->location, 'location' => $job->location,
'salary' => (int) $job->salary, 'salary' => (int) $job->salary,
'workers_needed' => $job->workers_needed, 'workers_needed' => $job->workers_needed,
@ -368,8 +433,17 @@ public function employerUpdateJob(Request $request, $id)
], 404); ], 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(), [ $validator = Validator::make($request->all(), [
'title' => 'required|string|max:255', 'title' => 'required|string|max:255',
'main_profession' => 'required|string|max:255',
'skills' => 'required',
'workers_needed' => 'required|integer|min:1', 'workers_needed' => 'required|integer|min:1',
'location' => 'required|string|max:255', 'location' => 'required|string|max:255',
'salary' => 'required|numeric|min:0', 'salary' => 'required|numeric|min:0',
@ -390,6 +464,7 @@ public function employerUpdateJob(Request $request, $id)
$job->update([ $job->update([
'title' => $request->title, 'title' => $request->title,
'main_profession' => $request->main_profession,
'workers_needed' => $request->workers_needed, 'workers_needed' => $request->workers_needed,
'job_type' => $request->job_type, 'job_type' => $request->job_type,
'location' => $request->location, 'location' => $request->location,
@ -400,6 +475,27 @@ public function employerUpdateJob(Request $request, $id)
'status' => strtolower($request->status), '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([ return response()->json([
'success' => true, 'success' => true,
'message' => 'Job updated successfully.', 'message' => 'Job updated successfully.',
@ -407,6 +503,8 @@ public function employerUpdateJob(Request $request, $id)
'job' => [ 'job' => [
'id' => $job->id, 'id' => $job->id,
'title' => $job->title, 'title' => $job->title,
'main_profession' => $job->main_profession,
'skills' => $job->skills()->pluck('name')->toArray(),
'location' => $job->location, 'location' => $job->location,
'salary' => (int) $job->salary, 'salary' => (int) $job->salary,
'workers_needed' => $job->workers_needed, 'workers_needed' => $job->workers_needed,
@ -562,7 +660,7 @@ public function employerUpdateApplicationStatus(Request $request, $id)
} }
$validator = Validator::make($request->all(), [ $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', 'notes' => 'nullable|string',
]); ]);
@ -587,9 +685,12 @@ public function employerUpdateApplicationStatus(Request $request, $id)
} }
$dbStatus = strtolower($request->status); $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 // Contact Limit check: if moving to 'shortlisted', 'contacted', 'interview_scheduled', 'selected', 'hired', 'hire_requested'
$contactStatuses = ['shortlisted', 'contacted', 'interview_scheduled', 'selected', 'hired']; $contactStatuses = ['shortlisted', 'contacted', 'interview_scheduled', 'selected', 'hired', 'hire_requested'];
if (in_array($dbStatus, $contactStatuses)) { if (in_array($dbStatus, $contactStatuses)) {
if (!User::canContactWorker($employer->id, $application->worker_id)) { if (!User::canContactWorker($employer->id, $application->worker_id)) {
$activeSub = \App\Models\Subscription::where('user_id', $employer->id)->where('status', 'active')->first(); $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 === 'rejected') {
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 ($application->worker) { if ($application->worker) {
$application->worker->update(['status' => 'active']); $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) private function triggerStatusNotification($application, $status)
{ {
@ -766,61 +1005,76 @@ private function triggerStatusNotification($application, $status)
// When a new application is submitted: notify employer // When a new application is submitted: notify employer
if ($status === 'applied') { if ($status === 'applied') {
if ($employer && $employer->fcm_token) { if ($employer) {
\App\Services\FCMService::sendPushNotification( $employer->notify(new \App\Notifications\GenericNotification(
$employer->fcm_token,
"New Job Application", "New Job Application",
"A candidate has applied for your job: " . ($job->title ?? 'Job Post'), "A candidate has applied for your job: " . ($job->title ?? 'Job Post'),
[ [
'type' => 'new_job_application', 'type' => 'job_application_submitted',
'job_id' => $job->id, 'job_id' => $job->id,
'application_id' => $application->id, 'application_id' => $application->id,
] ]
); ));
} }
return; return;
} }
// When status is updated: notify worker // When status is updated: notify worker
if ($worker && $worker->fcm_token) { if ($worker) {
$title = "Job Application Update"; $title = "Job Application Update";
$body = ""; $body = "";
$type = 'job_application_status_update';
switch (strtolower($status)) { switch (strtolower($status)) {
case 'shortlisted': case 'shortlisted':
$body = "You have been shortlisted for the job: " . ($job->title ?? 'Job Post'); $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; break;
case 'contacted': case 'contacted':
$body = "An employer wants to contact you regarding the job: " . ($job->title ?? 'Job Post'); $body = "An employer wants to contact you regarding the job: " . ($job->title ?? 'Job Post');
$type = 'contacted';
break; break;
case 'interview scheduled': case 'interview scheduled':
case 'interview_scheduled': case 'interview_scheduled':
$body = "An interview has been scheduled for the job: " . ($job->title ?? 'Job Post'); $body = "An interview has been scheduled for the job: " . ($job->title ?? 'Job Post');
$type = 'interview_scheduled';
break; break;
case 'selected': case 'selected':
$body = "Congratulations! You have been selected for the job: " . ($job->title ?? 'Job Post'); $body = "Congratulations! You have been selected for the job: " . ($job->title ?? 'Job Post');
$type = 'selected';
break; break;
case 'rejected': case 'rejected':
$body = "Your application for the job: " . ($job->title ?? 'Job Post') . " has been reviewed and 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; break;
case 'hired': case 'hired':
$body = "Congratulations! You have been hired for the job: " . ($job->title ?? 'Job Post'); $body = "Congratulations! You have been hired for the job: " . ($job->title ?? 'Job Post');
$type = 'hired';
break; break;
default: default:
$body = "Your application status for the job: " . ($job->title ?? 'Job Post') . " has changed to " . ucfirst($status); $body = "Your application status for the job: " . ($job->title ?? 'Job Post') . " has changed to " . ucfirst($status);
break; break;
} }
\App\Services\FCMService::sendPushNotification( $worker->notify(new \App\Notifications\GenericNotification(
$worker->fcm_token,
$title, $title,
$body, $body,
[ [
'type' => 'job_application_status_update', 'type' => $type,
'job_id' => $job->id ?? '', 'job_id' => $job->id ?? '',
'application_id' => $application->id, 'application_id' => $application->id,
'status' => $status, 'status' => $status,
] ]
); ));
} }
} }
} }

View File

@ -78,7 +78,7 @@ public function updateProfile(Request $request)
'skills' => 'nullable|array', 'skills' => 'nullable|array',
'skills.*' => 'exists:skills,id', 'skills.*' => 'exists:skills,id',
'in_country' => 'nullable', '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', 'preferred_job_type' => 'nullable|string|max:100',
'gender' => 'nullable|string|in:male,female,other', 'gender' => 'nullable|string|in:male,female,other',
'live_in_out' => 'nullable|string|in:live_in,live_out', '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 // Dispatch push notification to employer
$employer = $offer->employer; $employer = $offer->employer;
if ($employer && $employer->fcm_token) { if ($employer) {
$statusMessage = $responseStatus === 'accepted' ? 'accepted' : 'rejected'; $statusMessage = $responseStatus === 'accepted' ? 'accepted' : 'rejected';
\App\Services\FCMService::sendPushNotification( $employer->notify(new \App\Notifications\GenericNotification(
$employer->fcm_token,
"Job Offer " . ucfirst($statusMessage), "Job Offer " . ucfirst($statusMessage),
"Worker " . ($worker->name ?? "Candidate") . " has " . $statusMessage . " your job offer.", "Worker " . ($worker->name ?? "Candidate") . " has " . $statusMessage . " your job offer.",
[ [
@ -483,7 +482,7 @@ public function respondToOffer(Request $request, $id)
'offer_id' => $offer->id, 'offer_id' => $offer->id,
'status' => $responseStatus, 'status' => $responseStatus,
] ]
); ));
} }
return response()->json([ return response()->json([

View File

@ -316,7 +316,7 @@ private function checkJobAccess($user)
public function updateStatus(Request $request, $id) public function updateStatus(Request $request, $id)
{ {
$request->validate([ $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', 'notes' => 'nullable|string',
]); ]);
@ -327,18 +327,31 @@ public function updateStatus(Request $request, $id)
$dbStatus = 'pending'; $dbStatus = 'pending';
if (in_array(strtolower($request->status), ['hired', 'accepted'])) { if (in_array(strtolower($request->status), ['hired', 'accepted'])) {
$dbStatus = 'accepted'; // Under two-way confirmation, it must NOT immediately set status to accepted/Hired.
$offer->worker->update(['status' => 'Hired']); // It should set offer status to 'pending' (waiting for worker confirmation).
$dbStatus = 'pending';
} elseif (in_array(strtolower($request->status), ['rejected'])) { } elseif (in_array(strtolower($request->status), ['rejected'])) {
$dbStatus = 'rejected'; $dbStatus = 'rejected';
$offer->worker->update(['status' => 'active']);
} elseif (in_array(strtolower($request->status), ['offer sent', 'offer_sent', 'pending'])) { } elseif (in_array(strtolower($request->status), ['offer sent', 'offer_sent', 'pending'])) {
$dbStatus = 'pending'; $dbStatus = 'pending';
} }
$offer->update(['status' => $dbStatus]); $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 // Standard job application status update
@ -356,8 +369,8 @@ public function updateStatus(Request $request, $id)
$dbStatus = 'applied'; $dbStatus = 'applied';
$statusLower = strtolower($request->status); $statusLower = strtolower($request->status);
if ($statusLower === 'hired') { if ($statusLower === 'hired' || $statusLower === 'hire_requested') {
$dbStatus = 'hired'; $dbStatus = 'hire_requested';
} elseif ($statusLower === 'rejected') { } elseif ($statusLower === 'rejected') {
$dbStatus = 'rejected'; $dbStatus = 'rejected';
} elseif ($statusLower === 'shortlisted' || $statusLower === 'offer sent' || $statusLower === 'offer_sent') { } elseif ($statusLower === 'shortlisted' || $statusLower === 'offer sent' || $statusLower === 'offer_sent') {
@ -372,8 +385,8 @@ public function updateStatus(Request $request, $id)
$dbStatus = 'applied'; $dbStatus = 'applied';
} }
// Contact Limit check: if moving to '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']; $contactStatuses = ['shortlisted', 'contacted', 'interview_scheduled', 'selected', 'hired', 'hire_requested'];
if (in_array($dbStatus, $contactStatuses)) { if (in_array($dbStatus, $contactStatuses)) {
if (!User::canContactWorker($user->id, $app->worker_id)) { if (!User::canContactWorker($user->id, $app->worker_id)) {
$activeSub = \App\Models\Subscription::where('user_id', $user->id)->where('status', 'active')->first(); $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 === 'rejected') {
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 ($app->worker) { if ($app->worker) {
$app->worker->update(['status' => 'active']); $app->worker->update(['status' => 'active']);
} }
@ -458,61 +452,76 @@ private function triggerStatusNotification($application, $status)
// When a new application is submitted: notify employer // When a new application is submitted: notify employer
if ($status === 'applied') { if ($status === 'applied') {
if ($employer && $employer->fcm_token) { if ($employer) {
\App\Services\FCMService::sendPushNotification( $employer->notify(new \App\Notifications\GenericNotification(
$employer->fcm_token,
"New Job Application", "New Job Application",
"A candidate has applied for your job: " . ($job->title ?? 'Job Post'), "A candidate has applied for your job: " . ($job->title ?? 'Job Post'),
[ [
'type' => 'new_job_application', 'type' => 'job_application_submitted',
'job_id' => $job->id, 'job_id' => $job->id,
'application_id' => $application->id, 'application_id' => $application->id,
] ]
); ));
} }
return; return;
} }
// When status is updated: notify worker // When status is updated: notify worker
if ($worker && $worker->fcm_token) { if ($worker) {
$title = "Job Application Update"; $title = "Job Application Update";
$body = ""; $body = "";
$type = 'job_application_status_update';
switch (strtolower($status)) { switch (strtolower($status)) {
case 'shortlisted': case 'shortlisted':
$body = "You have been shortlisted for the job: " . ($job->title ?? 'Job Post'); $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; break;
case 'contacted': case 'contacted':
$body = "An employer wants to contact you regarding the job: " . ($job->title ?? 'Job Post'); $body = "An employer wants to contact you regarding the job: " . ($job->title ?? 'Job Post');
$type = 'contacted';
break; break;
case 'interview scheduled': case 'interview scheduled':
case 'interview_scheduled': case 'interview_scheduled':
$body = "An interview has been scheduled for the job: " . ($job->title ?? 'Job Post'); $body = "An interview has been scheduled for the job: " . ($job->title ?? 'Job Post');
$type = 'interview_scheduled';
break; break;
case 'selected': case 'selected':
$body = "Congratulations! You have been selected for the job: " . ($job->title ?? 'Job Post'); $body = "Congratulations! You have been selected for the job: " . ($job->title ?? 'Job Post');
$type = 'selected';
break; break;
case 'rejected': case 'rejected':
$body = "Your application for the job: " . ($job->title ?? 'Job Post') . " has been reviewed and 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; break;
case 'hired': case 'hired':
$body = "Congratulations! You have been hired for the job: " . ($job->title ?? 'Job Post'); $body = "Congratulations! You have been hired for the job: " . ($job->title ?? 'Job Post');
$type = 'hired';
break; break;
default: default:
$body = "Your application status for the job: " . ($job->title ?? 'Job Post') . " has changed to " . ucfirst($status); $body = "Your application status for the job: " . ($job->title ?? 'Job Post') . " has changed to " . ucfirst($status);
break; break;
} }
\App\Services\FCMService::sendPushNotification( $worker->notify(new \App\Notifications\GenericNotification(
$worker->fcm_token,
$title, $title,
$body, $body,
[ [
'type' => 'job_application_status_update', 'type' => $type,
'job_id' => $job->id ?? '', 'job_id' => $job->id ?? '',
'application_id' => $application->id, 'application_id' => $application->id,
'status' => $status, 'status' => $status,
] ]
); ));
} }
} }
} }

View File

@ -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.'); ->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) public function store(Request $request)
@ -156,6 +181,8 @@ public function store(Request $request)
$request->validate([ $request->validate([
'title' => 'required|string|max:255', 'title' => 'required|string|max:255',
'main_profession' => 'required|string|max:255',
'skills' => 'required',
'workers_needed' => 'required|integer|min:1', 'workers_needed' => 'required|integer|min:1',
'location' => 'required|string|max:255', 'location' => 'required|string|max:255',
'salary' => 'required|numeric|min:0', 'salary' => 'required|numeric|min:0',
@ -165,9 +192,10 @@ public function store(Request $request)
'requirements' => 'nullable|string', 'requirements' => 'nullable|string',
]); ]);
JobPost::create([ $job = JobPost::create([
'employer_id' => $user->id, 'employer_id' => $user->id,
'title' => $request->title, 'title' => $request->title,
'main_profession' => $request->main_profession,
'workers_needed' => $request->workers_needed, 'workers_needed' => $request->workers_needed,
'job_type' => $request->job_type, 'job_type' => $request->job_type,
'location' => $request->location, 'location' => $request->location,
@ -178,6 +206,27 @@ public function store(Request $request)
'status' => 'active', '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.'); 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.'); ->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 // Convert start_date format to Y-m-d for date input
$jobData = $job->toArray(); $jobData = $job->toArray();
@ -250,8 +299,40 @@ public function edit($id)
$jobData['start_date'] = $job->start_date->format('Y-m-d'); $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', [ return Inertia::render('Employer/Jobs/Edit', [
'job' => $jobData, '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(); $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([ $request->validate([
'title' => 'required|string|max:255', 'title' => 'required|string|max:255',
'main_profession' => 'required|string|max:255',
'skills' => 'required',
'workers_needed' => 'required|integer|min:1', 'workers_needed' => 'required|integer|min:1',
'location' => 'required|string|max:255', 'location' => 'required|string|max:255',
'salary' => 'required|numeric|min:0', 'salary' => 'required|numeric|min:0',
@ -283,6 +370,7 @@ public function update(Request $request, $id)
$job->update([ $job->update([
'title' => $request->title, 'title' => $request->title,
'main_profession' => $request->main_profession,
'workers_needed' => $request->workers_needed, 'workers_needed' => $request->workers_needed,
'job_type' => $request->job_type, 'job_type' => $request->job_type,
'location' => $request->location, 'location' => $request->location,
@ -293,6 +381,27 @@ public function update(Request $request, $id)
'status' => strtolower($request->status), '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.'); return redirect()->route('employer.jobs')->with('success', 'Job updated successfully.');
} }

View File

@ -37,6 +37,21 @@ private function resolveCurrentUser()
return null; 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) public static function processWorkerResponse($conv, $worker, $text)
{ {
$replyText = strtolower(trim($text)); $replyText = strtolower(trim($text));
@ -105,13 +120,13 @@ public function index(Request $request)
->latest('updated_at') ->latest('updated_at')
->get(); ->get();
$conversations = $dbConversations->map(function ($conv) { $conversations = $dbConversations->map(function ($conv) use ($user) {
$lastMsg = $conv->messages->last(); $lastMsg = $conv->messages->last();
return [ return [
'id' => $conv->id, 'id' => $conv->id,
'worker_id' => $conv->worker_id, 'worker_id' => $conv->worker_id,
'worker_name' => $conv->worker->name ?? 'Candidate', '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.', 'last_message' => $lastMsg->text ?? 'No messages yet.',
'unread' => $lastMsg ? ($lastMsg->sender_type === 'worker' && is_null($lastMsg->read_at)) : false, 'unread' => $lastMsg ? ($lastMsg->sender_type === 'worker' && is_null($lastMsg->read_at)) : false,
'online' => true, 'online' => true,
@ -136,13 +151,13 @@ public function show($id)
->latest('updated_at') ->latest('updated_at')
->get(); ->get();
$conversations = $dbConversations->map(function ($conv) { $conversations = $dbConversations->map(function ($conv) use ($user) {
$lastMsg = $conv->messages->last(); $lastMsg = $conv->messages->last();
return [ return [
'id' => $conv->id, 'id' => $conv->id,
'worker_id' => $conv->worker_id, 'worker_id' => $conv->worker_id,
'worker_name' => $conv->worker->name ?? 'Candidate', '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.', 'last_message' => $lastMsg->text ?? 'No messages yet.',
'unread' => $lastMsg ? ($lastMsg->sender_type === 'worker' && is_null($lastMsg->read_at)) : false, 'unread' => $lastMsg ? ($lastMsg->sender_type === 'worker' && is_null($lastMsg->read_at)) : false,
'online' => true, 'online' => true,
@ -165,7 +180,7 @@ public function show($id)
'id' => $activeConv->id, 'id' => $activeConv->id,
'worker_id' => $activeConv->worker_id, 'worker_id' => $activeConv->worker_id,
'worker_name' => $activeConv->worker->name ?? 'Candidate', '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, 'online' => true,
'salary' => ($activeConv->worker->salary ?? 2000) . ' AED', 'salary' => ($activeConv->worker->salary ?? 2000) . ' AED',
'nationality' => $activeConv->worker->nationality ?? 'Unknown', 'nationality' => $activeConv->worker->nationality ?? 'Unknown',

View File

@ -365,6 +365,16 @@ public function show($id)
$visaStatus = $w->visa_status; $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 = [ $worker = [
'id' => $w->id, 'id' => $w->id,
'name' => $w->name, 'name' => $w->name,
@ -392,7 +402,7 @@ public function show($id)
'reviews' => $reviews, 'reviews' => $reviews,
'similar_workers' => $similarWorkers, 'similar_workers' => $similarWorkers,
'status' => $w->status, '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_status' => $w->document_expiry_status,
'document_expiry_days' => $w->document_expiry_days, 'document_expiry_days' => $w->document_expiry_days,
'visa_expiry_date' => $w->visa_expiry_date, 'visa_expiry_date' => $w->visa_expiry_date,
@ -479,9 +489,8 @@ public function markHired(Request $request, $id)
} }
$worker = Worker::findOrFail($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 // Check if there is an existing job offer for this employer and worker
$offer = JobOffer::where('employer_id', $employerId) $offer = JobOffer::where('employer_id', $employerId)
@ -489,7 +498,7 @@ public function markHired(Request $request, $id)
->first(); ->first();
if ($offer) { if ($offer) {
$offer->update(['status' => 'accepted']); $offer->update(['status' => 'pending']);
} else { } else {
// Also check if there's an existing job application // Also check if there's an existing job application
$jobIds = \App\Models\JobPost::where('employer_id', $employerId)->pluck('id'); $jobIds = \App\Models\JobPost::where('employer_id', $employerId)->pluck('id');
@ -498,21 +507,41 @@ public function markHired(Request $request, $id)
->first(); ->first();
if ($application) { 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 { } else {
// Create a new direct job offer with status accepted // Create a new direct job offer with status pending
JobOffer::create([ $offer = JobOffer::create([
'employer_id' => $employerId, 'employer_id' => $employerId,
'worker_id' => $worker->id, 'worker_id' => $worker->id,
'work_date' => now()->format('Y-m-d'), 'work_date' => now()->format('Y-m-d'),
'location' => 'Dubai', 'location' => 'Dubai',
'salary' => $worker->salary, 'salary' => $worker->salary,
'notes' => 'Direct sponsoring matching finalized.', 'notes' => 'Direct sponsoring matching initiated.',
'status' => 'accepted', '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.');
} }
} }

View File

@ -13,6 +13,7 @@ class JobPost extends Model
protected $fillable = [ protected $fillable = [
'employer_id', 'employer_id',
'title', 'title',
'main_profession',
'workers_needed', 'workers_needed',
'job_type', 'job_type',
'location', 'location',
@ -34,6 +35,10 @@ public function employer()
return $this->belongsTo(User::class, 'employer_id'); 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() public function applications()
{ {

View File

@ -0,0 +1,65 @@
<?php
namespace App\Notifications;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Notification;
use App\Notifications\Channels\FcmChannel;
class GenericNotification extends Notification
{
use Queueable;
public $title;
public $body;
public $data;
/**
* Create a new notification instance.
*/
public function __construct($title, $body, array $data = [])
{
$this->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),
];
}
}

View File

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

View File

@ -193,10 +193,11 @@ export default function Applicants({ job, applicants: initialApplicants, isShort
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className={`px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-widest cursor-pointer hover:opacity-85 transition-opacity ${ <span className={`px-3 py-1 rounded-full text-[10px] font-black uppercase tracking-widest cursor-pointer hover:opacity-85 transition-opacity ${
applicant.status?.toLowerCase() === 'hired' ? 'bg-emerald-100 text-emerald-700' : applicant.status?.toLowerCase() === 'hired' ? 'bg-emerald-100 text-emerald-700' :
applicant.status?.toLowerCase() === 'hire_requested' ? 'bg-amber-50 text-amber-600 border border-amber-200' :
applicant.status?.toLowerCase() === 'rejected' ? 'bg-rose-100 text-rose-700' : applicant.status?.toLowerCase() === 'rejected' ? 'bg-rose-100 text-rose-700' :
'bg-amber-100 text-amber-700' 'bg-blue-50 text-[#185FA5]'
}`} onClick={() => openStatusModal(applicant)}> }`} onClick={() => openStatusModal(applicant)}>
{applicant.status} {applicant.status?.toLowerCase() === 'hire_requested' ? 'Pending Confirmation' : applicant.status}
</span> </span>
<div className="flex items-center space-x-1 text-[10px] font-bold text-slate-400"> <div className="flex items-center space-x-1 text-[10px] font-bold text-slate-400">
<Link href={`/employer/workers/${applicant.id}`} className="hover:text-[#185FA5] flex items-center underline underline-offset-2"> <Link href={`/employer/workers/${applicant.id}`} className="hover:text-[#185FA5] flex items-center underline underline-offset-2">
@ -261,6 +262,11 @@ export default function Applicants({ job, applicants: initialApplicants, isShort
{/* Modal Body */} {/* Modal Body */}
<div className="p-6 space-y-5"> <div className="p-6 space-y-5">
{selectedApp?.status?.toLowerCase() === 'hire_requested' && (
<div className="p-3 bg-amber-50 rounded-xl border border-amber-100 text-[10px] text-amber-700 font-bold">
A hiring offer is pending. Waiting for worker confirmation.
</div>
)}
{/* Status Selector */} {/* Status Selector */}
<div className="space-y-2"> <div className="space-y-2">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Select Stage</label> <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Select Stage</label>
@ -275,7 +281,7 @@ export default function Applicants({ job, applicants: initialApplicants, isShort
<option value="Interview Scheduled">Interview Scheduled</option> <option value="Interview Scheduled">Interview Scheduled</option>
<option value="Selected">Selected</option> <option value="Selected">Selected</option>
<option value="Rejected">Rejected</option> <option value="Rejected">Rejected</option>
<option value="Hired">Hired</option> <option value="Hired">Hired (Request Confirmation)</option>
</select> </select>
</div> </div>

View File

@ -7,29 +7,31 @@ import {
Briefcase, Briefcase,
MapPin, MapPin,
DollarSign, DollarSign,
Calendar,
LayoutGrid, LayoutGrid,
Users, Users,
FileText, FileText,
Sparkles, Sparkles,
ShieldCheck ShieldCheck,
X
} from 'lucide-react'; } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
export default function Create({ categories: initialCategories }) { export default function Create({ professions, availableSkills }) {
const categories = initialCategories || [ const categoryList = professions || [
'Electrician', 'Mason', 'Plumber', 'Cleaner', 'Site Supervisor', 'Driver', 'General Helper' 'Housekeeper', 'Nanny', 'Maid', 'Electrician', 'Mason', 'Plumber', 'Cleaner', 'Driver', 'Caregiver', 'Cook'
]; ];
const skillList = availableSkills || [];
const { data, setData, post, processing, errors } = useForm({ const { data, setData, post, processing, errors } = useForm({
title: '', title: '',
main_profession: '',
skills: [],
workers_needed: 1, workers_needed: 1,
location: '', location: '',
salary: '', salary: '',
job_type: 'Full Time', job_type: 'Full Time',
start_date: '', start_date: new Date().toISOString().split('T')[0],
description: '', description: '',
requirements: ''
}); });
const [clientErrors, setClientErrors] = useState({}); const [clientErrors, setClientErrors] = useState({});
@ -42,6 +44,12 @@ export default function Create({ categories: initialCategories }) {
if (!data.title.trim()) { if (!data.title.trim()) {
newErrors.title = 'Job title is required.'; 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) { if (!data.workers_needed || data.workers_needed < 1) {
newErrors.workers_needed = 'Workers needed must be at least 1.'; newErrors.workers_needed = 'Workers needed must be at least 1.';
} }
@ -133,7 +141,29 @@ export default function Create({ categories: initialCategories }) {
)} )}
</div> </div>
<div className="space-y-2"> <div className="space-y-2 col-span-2 md:col-span-1">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Main Profession</label>
<div className="relative">
<Briefcase className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<select
className={`w-full pl-11 pr-10 py-3 bg-slate-50 border rounded-2xl text-sm font-bold text-slate-900 focus:ring-4 focus:ring-blue-500/10 focus:border-[#185FA5] outline-none transition-all appearance-none cursor-pointer ${
(clientErrors.main_profession || errors.main_profession) ? 'border-red-500 bg-red-50/20' : 'border-slate-100'
}`}
value={data.main_profession}
onChange={e => setData('main_profession', e.target.value)}
>
<option value="">Select main profession...</option>
{categoryList.map(prof => (
<option key={prof} value={prof}>{prof}</option>
))}
</select>
</div>
{(clientErrors.main_profession || errors.main_profession) && (
<p className="text-xs font-bold text-red-500 mt-1 ml-1">{clientErrors.main_profession || errors.main_profession}</p>
)}
</div>
<div className="space-y-2 col-span-2 md:col-span-1">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Workers Needed</label> <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Workers Needed</label>
<div className="relative"> <div className="relative">
<Users className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" /> <Users className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
@ -152,37 +182,7 @@ export default function Create({ categories: initialCategories }) {
)} )}
</div> </div>
<div className="space-y-2"> <div className="space-y-2 col-span-2 md:col-span-1">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Job Type</label>
<div className="grid grid-cols-3 gap-2">
{['Full Time', 'Part Time', 'Contract'].map(type => (
<button
key={type}
type="button"
onClick={() => setData('job_type', type)}
className={`py-3 text-[10px] font-black rounded-xl border transition-all ${
data.job_type === type
? 'bg-[#185FA5] text-white border-[#185FA5] shadow-md shadow-blue-500/20'
: 'bg-white text-slate-400 border-slate-100 hover:border-slate-300'
}`}
>
{type.toUpperCase()}
</button>
))}
</div>
</div>
</div>
</div>
{/* Section 2: Logistics & Pay */}
<div className="space-y-6">
<h3 className="text-xs font-black text-[#185FA5] uppercase tracking-[0.2em] flex items-center">
<div className="w-1.5 h-1.5 bg-[#185FA5] rounded-full mr-2" />
Logistics & Compensation
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="space-y-2">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Job Location</label> <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Job Location</label>
<div className="relative"> <div className="relative">
<MapPin className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" /> <MapPin className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
@ -201,7 +201,7 @@ export default function Create({ categories: initialCategories }) {
)} )}
</div> </div>
<div className="space-y-2"> <div className="space-y-2 col-span-2 md:col-span-1">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Salary (AED / mo)</label> <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Salary (AED / mo)</label>
<div className="relative"> <div className="relative">
<DollarSign className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" /> <DollarSign className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
@ -220,27 +220,29 @@ export default function Create({ categories: initialCategories }) {
)} )}
</div> </div>
<div className="space-y-2"> <div className="space-y-2 col-span-2 md:col-span-1">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Start Date</label> <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Job Type</label>
<div className="relative"> <div className="grid grid-cols-2 gap-2">
<Calendar className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" /> {['Full Time', 'Part Time'].map(type => (
<input <button
type="date" key={type}
className={`w-full pl-11 pr-4 py-3 bg-slate-50 border rounded-2xl text-sm font-bold text-slate-900 focus:ring-4 focus:ring-blue-500/10 focus:border-[#185FA5] outline-none transition-all ${ type="button"
(clientErrors.start_date || errors.start_date) ? 'border-red-500 bg-red-50/20' : 'border-slate-100' onClick={() => setData('job_type', type)}
}`} className={`py-3 text-[10px] font-black rounded-xl border transition-all ${
value={data.start_date} data.job_type === type
onChange={e => setData('start_date', e.target.value)} ? 'bg-[#185FA5] text-white border-[#185FA5] shadow-md shadow-blue-500/20'
/> : 'bg-white text-slate-400 border-slate-100 hover:border-slate-300'
}`}
>
{type.toUpperCase()}
</button>
))}
</div> </div>
{(clientErrors.start_date || errors.start_date) && (
<p className="text-xs font-bold text-red-500 mt-1 ml-1">{clientErrors.start_date || errors.start_date}</p>
)}
</div> </div>
</div> </div>
</div> </div>
{/* Section 3: Details */} {/* Section 2: Detailed Requirements */}
<div className="space-y-6"> <div className="space-y-6">
<h3 className="text-xs font-black text-[#185FA5] uppercase tracking-[0.2em] flex items-center"> <h3 className="text-xs font-black text-[#185FA5] uppercase tracking-[0.2em] flex items-center">
<div className="w-1.5 h-1.5 bg-[#185FA5] rounded-full mr-2" /> <div className="w-1.5 h-1.5 bg-[#185FA5] rounded-full mr-2" />
@ -248,6 +250,56 @@ export default function Create({ categories: initialCategories }) {
</h3> </h3>
<div className="space-y-6"> <div className="space-y-6">
<div className="space-y-2">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Skills (Select from list)</label>
<div className="space-y-3">
<div className="relative">
<Briefcase className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<select
className={`w-full pl-11 pr-10 py-3 bg-slate-50 border rounded-2xl text-sm font-bold text-slate-900 focus:ring-4 focus:ring-blue-500/10 focus:border-[#185FA5] outline-none transition-all appearance-none cursor-pointer ${
(clientErrors.skills || errors.skills) ? 'border-red-500 bg-red-50/20' : 'border-slate-100'
}`}
value=""
onChange={e => {
const val = e.target.value;
if (val && !data.skills.includes(val)) {
setData('skills', [...data.skills, val]);
}
}}
>
<option value="">Choose skills to add...</option>
{skillList.filter(s => !data.skills.includes(s.name)).map(skill => (
<option key={skill.id} value={skill.name}>{skill.name}</option>
))}
</select>
</div>
{/* Selected skills pills */}
{data.skills.length > 0 && (
<div className="flex flex-wrap gap-2 p-3 bg-slate-50 rounded-2xl border border-slate-100">
{data.skills.map(skill => (
<span
key={skill}
className="inline-flex items-center space-x-1.5 px-3 py-1.5 bg-[#185FA5] text-white rounded-xl text-xs font-black uppercase tracking-wider shadow-xs"
>
<span>{skill}</span>
<button
type="button"
onClick={() => setData('skills', data.skills.filter(s => s !== skill))}
className="hover:bg-blue-700/50 p-0.5 rounded-full transition-colors"
>
<X className="w-3.5 h-3.5 text-white" />
</button>
</span>
))}
</div>
)}
</div>
{(clientErrors.skills || errors.skills) && (
<p className="text-xs font-bold text-red-500 mt-1 ml-1">{clientErrors.skills || errors.skills}</p>
)}
</div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Job Description</label> <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Job Description</label>
<textarea <textarea
@ -268,19 +320,6 @@ export default function Create({ categories: initialCategories }) {
</div> </div>
</div> </div>
</div> </div>
<div className="space-y-2">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Special Requirements (Optional)</label>
<div className="relative">
<FileText className="absolute left-4 top-5 w-4 h-4 text-slate-400" />
<textarea
placeholder="e.g. Must speak Arabic, 5+ years experience, transferable visa..."
className="w-full pl-11 pr-4 py-4 bg-slate-50 border border-slate-100 rounded-2xl text-sm font-bold text-slate-900 focus:ring-4 focus:ring-blue-500/10 focus:border-[#185FA5] outline-none transition-all min-h-[100px] resize-none"
value={data.requirements}
onChange={e => setData('requirements', e.target.value)}
/>
</div>
</div>
</div> </div>
</div> </div>

View File

@ -7,26 +7,32 @@ import {
Briefcase, Briefcase,
MapPin, MapPin,
DollarSign, DollarSign,
Calendar,
LayoutGrid, LayoutGrid,
Users, Users,
FileText, FileText,
Sparkles, Sparkles,
ShieldCheck, ShieldCheck,
Save Save,
X
} from 'lucide-react'; } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
export default function Edit({ job }) { export default function Edit({ job, 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({ const { data, setData, post, processing, errors } = useForm({
title: job.title || '', title: job.title || '',
main_profession: job.main_profession || '',
skills: job.skills ? job.skills.map(s => s.name) : [],
workers_needed: job.workers_needed || 1, workers_needed: job.workers_needed || 1,
location: job.location || '', location: job.location || '',
salary: job.salary ? Math.round(job.salary) : '', salary: job.salary ? Math.round(job.salary) : '',
job_type: job.job_type || 'Full Time', job_type: job.job_type || 'Full Time',
start_date: job.start_date || '', start_date: job.start_date || new Date().toISOString().split('T')[0],
description: job.description || '', description: job.description || '',
requirements: job.requirements || '',
status: job.status || 'active' status: job.status || 'active'
}); });
@ -40,6 +46,12 @@ export default function Edit({ job }) {
if (!data.title.trim()) { if (!data.title.trim()) {
newErrors.title = 'Job title is required.'; 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) { if (!data.workers_needed || data.workers_needed < 1) {
newErrors.workers_needed = 'Workers needed must be at least 1.'; newErrors.workers_needed = 'Workers needed must be at least 1.';
} }
@ -130,7 +142,29 @@ export default function Edit({ job }) {
)} )}
</div> </div>
<div className="space-y-2"> <div className="space-y-2 col-span-2 md:col-span-1">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Main Profession</label>
<div className="relative">
<Briefcase className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<select
className={`w-full pl-11 pr-10 py-3 bg-slate-50 border rounded-2xl text-sm font-bold text-slate-900 focus:ring-4 focus:ring-blue-500/10 focus:border-[#185FA5] outline-none transition-all appearance-none cursor-pointer ${
(clientErrors.main_profession || errors.main_profession) ? 'border-red-500 bg-red-50/20' : 'border-slate-100'
}`}
value={data.main_profession}
onChange={e => setData('main_profession', e.target.value)}
>
<option value="">Select main profession...</option>
{categoryList.map(prof => (
<option key={prof} value={prof}>{prof}</option>
))}
</select>
</div>
{(clientErrors.main_profession || errors.main_profession) && (
<p className="text-xs font-bold text-red-500 mt-1 ml-1">{clientErrors.main_profession || errors.main_profession}</p>
)}
</div>
<div className="space-y-2 col-span-2 md:col-span-1">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Workers Needed</label> <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Workers Needed</label>
<div className="relative"> <div className="relative">
<Users className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" /> <Users className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
@ -149,10 +183,48 @@ export default function Edit({ job }) {
)} )}
</div> </div>
<div className="space-y-2"> <div className="space-y-2 col-span-2 md:col-span-1">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Job Location</label>
<div className="relative">
<MapPin className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type="text"
placeholder="e.g. Dubai Marina"
className={`w-full pl-11 pr-4 py-3 bg-slate-50 border rounded-2xl text-sm font-bold text-slate-900 focus:ring-4 focus:ring-blue-500/10 focus:border-[#185FA5] outline-none transition-all ${
(clientErrors.location || errors.location) ? 'border-red-500 bg-red-50/20' : 'border-slate-100'
}`}
value={data.location}
onChange={e => setData('location', e.target.value)}
/>
</div>
{(clientErrors.location || errors.location) && (
<p className="text-xs font-bold text-red-500 mt-1 ml-1">{clientErrors.location || errors.location}</p>
)}
</div>
<div className="space-y-2 col-span-2 md:col-span-1">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Salary (AED / mo)</label>
<div className="relative">
<DollarSign className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type="number"
placeholder="e.g. 2500"
className={`w-full pl-11 pr-4 py-3 bg-slate-50 border rounded-2xl text-sm font-bold text-slate-900 focus:ring-4 focus:ring-blue-500/10 focus:border-[#185FA5] outline-none transition-all ${
(clientErrors.salary || errors.salary) ? 'border-red-500 bg-red-50/20' : 'border-slate-100'
}`}
value={data.salary}
onChange={e => setData('salary', e.target.value)}
/>
</div>
{(clientErrors.salary || errors.salary) && (
<p className="text-xs font-bold text-red-500 mt-1 ml-1">{clientErrors.salary || errors.salary}</p>
)}
</div>
<div className="space-y-2 col-span-2 md:col-span-1">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Job Type</label> <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Job Type</label>
<div className="grid grid-cols-3 gap-2"> <div className="grid grid-cols-2 gap-2">
{['Full Time', 'Part Time', 'Contract'].map(type => ( {['Full Time', 'Part Time'].map(type => (
<button <button
key={type} key={type}
type="button" type="button"
@ -169,7 +241,7 @@ export default function Edit({ job }) {
</div> </div>
</div> </div>
<div className="space-y-2 col-span-2"> <div className="space-y-2 col-span-2 md:col-span-1">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Job Status</label> <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Job Status</label>
<div className="grid grid-cols-3 gap-2"> <div className="grid grid-cols-3 gap-2">
{[ {[
@ -195,73 +267,7 @@ export default function Edit({ job }) {
</div> </div>
</div> </div>
{/* Section 2: Logistics & Pay */} {/* Section 2: Detailed Requirements */}
<div className="space-y-6">
<h3 className="text-xs font-black text-[#185FA5] uppercase tracking-[0.2em] flex items-center">
<div className="w-1.5 h-1.5 bg-[#185FA5] rounded-full mr-2" />
Logistics & Compensation
</h3>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="space-y-2">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Job Location</label>
<div className="relative">
<MapPin className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type="text"
placeholder="e.g. Dubai Marina"
className={`w-full pl-11 pr-4 py-3 bg-slate-50 border rounded-2xl text-sm font-bold text-slate-900 focus:ring-4 focus:ring-blue-500/10 focus:border-[#185FA5] outline-none transition-all ${
(clientErrors.location || errors.location) ? 'border-red-500 bg-red-50/20' : 'border-slate-100'
}`}
value={data.location}
onChange={e => setData('location', e.target.value)}
/>
</div>
{(clientErrors.location || errors.location) && (
<p className="text-xs font-bold text-red-500 mt-1 ml-1">{clientErrors.location || errors.location}</p>
)}
</div>
<div className="space-y-2">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Salary (AED / mo)</label>
<div className="relative">
<DollarSign className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type="number"
placeholder="e.g. 2500"
className={`w-full pl-11 pr-4 py-3 bg-slate-50 border rounded-2xl text-sm font-bold text-slate-900 focus:ring-4 focus:ring-blue-500/10 focus:border-[#185FA5] outline-none transition-all ${
(clientErrors.salary || errors.salary) ? 'border-red-500 bg-red-50/20' : 'border-slate-100'
}`}
value={data.salary}
onChange={e => setData('salary', e.target.value)}
/>
</div>
{(clientErrors.salary || errors.salary) && (
<p className="text-xs font-bold text-red-500 mt-1 ml-1">{clientErrors.salary || errors.salary}</p>
)}
</div>
<div className="space-y-2">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Start Date</label>
<div className="relative">
<Calendar className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type="date"
className={`w-full pl-11 pr-4 py-3 bg-slate-50 border rounded-2xl text-sm font-bold text-slate-900 focus:ring-4 focus:ring-blue-500/10 focus:border-[#185FA5] outline-none transition-all ${
(clientErrors.start_date || errors.start_date) ? 'border-red-500 bg-red-50/20' : 'border-slate-100'
}`}
value={data.start_date}
onChange={e => setData('start_date', e.target.value)}
/>
</div>
{(clientErrors.start_date || errors.start_date) && (
<p className="text-xs font-bold text-red-500 mt-1 ml-1">{clientErrors.start_date || errors.start_date}</p>
)}
</div>
</div>
</div>
{/* Section 3: Details */}
<div className="space-y-6"> <div className="space-y-6">
<h3 className="text-xs font-black text-[#185FA5] uppercase tracking-[0.2em] flex items-center"> <h3 className="text-xs font-black text-[#185FA5] uppercase tracking-[0.2em] flex items-center">
<div className="w-1.5 h-1.5 bg-[#185FA5] rounded-full mr-2" /> <div className="w-1.5 h-1.5 bg-[#185FA5] rounded-full mr-2" />
@ -269,6 +275,56 @@ export default function Edit({ job }) {
</h3> </h3>
<div className="space-y-6"> <div className="space-y-6">
<div className="space-y-2">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Skills (Select from list)</label>
<div className="space-y-3">
<div className="relative">
<Briefcase className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<select
className={`w-full pl-11 pr-10 py-3 bg-slate-50 border rounded-2xl text-sm font-bold text-slate-900 focus:ring-4 focus:ring-blue-500/10 focus:border-[#185FA5] outline-none transition-all appearance-none cursor-pointer ${
(clientErrors.skills || errors.skills) ? 'border-red-500 bg-red-50/20' : 'border-slate-100'
}`}
value=""
onChange={e => {
const val = e.target.value;
if (val && !data.skills.includes(val)) {
setData('skills', [...data.skills, val]);
}
}}
>
<option value="">Choose skills to add...</option>
{skillList.filter(s => !data.skills.includes(s.name)).map(skill => (
<option key={skill.id} value={skill.name}>{skill.name}</option>
))}
</select>
</div>
{/* Selected skills pills */}
{data.skills.length > 0 && (
<div className="flex flex-wrap gap-2 p-3 bg-slate-50 rounded-2xl border border-slate-100">
{data.skills.map(skill => (
<span
key={skill}
className="inline-flex items-center space-x-1.5 px-3 py-1.5 bg-[#185FA5] text-white rounded-xl text-xs font-black uppercase tracking-wider shadow-xs"
>
<span>{skill}</span>
<button
type="button"
onClick={() => setData('skills', data.skills.filter(s => s !== skill))}
className="hover:bg-blue-700/50 p-0.5 rounded-full transition-colors"
>
<X className="w-3.5 h-3.5 text-white" />
</button>
</span>
))}
</div>
)}
</div>
{(clientErrors.skills || errors.skills) && (
<p className="text-xs font-bold text-red-500 mt-1 ml-1">{clientErrors.skills || errors.skills}</p>
)}
</div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Job Description</label> <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Job Description</label>
<textarea <textarea
@ -289,19 +345,6 @@ export default function Edit({ job }) {
</div> </div>
</div> </div>
</div> </div>
<div className="space-y-2">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Special Requirements (Optional)</label>
<div className="relative">
<FileText className="absolute left-4 top-5 w-4 h-4 text-slate-400" />
<textarea
placeholder="e.g. Must speak Arabic, 5+ years experience, transferable visa..."
className="w-full pl-11 pr-4 py-4 bg-slate-50 border border-slate-100 rounded-2xl text-sm font-bold text-slate-900 focus:ring-4 focus:ring-blue-500/10 focus:border-[#185FA5] outline-none transition-all min-h-[100px] resize-none"
value={data.requirements}
onChange={e => setData('requirements', e.target.value)}
/>
</div>
</div>
</div> </div>
</div> </div>

View File

@ -27,7 +27,8 @@ import {
UploadCloud, UploadCloud,
FileText, FileText,
Mic, Mic,
Square Square,
Clock
} from 'lucide-react'; } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
@ -192,7 +193,9 @@ export default function Show({ conversation, initialMessages, conversations = []
router.post(`/employer/workers/${conversation.worker_id}/mark-hired`, {}, { router.post(`/employer/workers/${conversation.worker_id}/mark-hired`, {}, {
preserveScroll: true, preserveScroll: true,
onSuccess: () => { onSuccess: () => {
toast.success(t('marked_hired', 'Worker successfully marked as Hired!')); toast.success(t('hire_request_sent', 'Hiring request sent successfully!'), {
description: t('hire_request_sent_desc', 'Awaiting worker confirmation of the hiring offer.')
});
router.reload(); router.reload();
} }
}); });
@ -312,7 +315,12 @@ export default function Show({ conversation, initialMessages, conversations = []
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
{conversation.worker_status !== 'hired' && conversation.worker_status !== 'hidden' ? ( {conversation.worker_status === 'pending_confirmation' ? (
<span className="px-3.5 py-2 bg-amber-50 text-amber-700 rounded-xl text-xs font-black uppercase tracking-wider border border-amber-200 flex items-center space-x-1.5">
<Clock className="w-4 h-4 text-amber-600 animate-pulse" />
<span>{t('pending_confirmation', 'Pending Confirmation')}</span>
</span>
) : conversation.worker_status !== 'hired' && conversation.worker_status !== 'hidden' ? (
<button <button
type="button" type="button"
onClick={() => setShowHireConfirmModal(true)} onClick={() => setShowHireConfirmModal(true)}

View File

@ -25,7 +25,8 @@ import {
AlertTriangle, AlertTriangle,
User, User,
Phone, Phone,
MapPin MapPin,
Clock
} from 'lucide-react'; } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
@ -161,10 +162,10 @@ export default function Show({ worker }) {
router.post(`/employer/workers/${worker.id}/mark-hired`, {}, { router.post(`/employer/workers/${worker.id}/mark-hired`, {}, {
preserveScroll: true, preserveScroll: true,
onSuccess: () => { onSuccess: () => {
setAvailabilityStatus('Hired'); setAvailabilityStatus('Pending Confirmation');
setShowHiredModal(true); setShowHiredModal(true);
toast.success(t('marked_hired', 'Worker successfully marked as Hired!'), { toast.success(t('hire_request_sent', 'Hiring request sent successfully!'), {
description: t('marked_hired_desc', 'Sponsorship direct match finalized. Please leave an anonymous trust review!') description: t('hire_request_sent_desc', 'Awaiting worker confirmation of the hiring offer.')
}); });
} }
}); });
@ -290,7 +291,12 @@ export default function Show({ worker }) {
</Link> </Link>
{/* Stage 4 Hire: Mark Hired (Finalize Hiring Direct Flow) */} {/* Stage 4 Hire: Mark Hired (Finalize Hiring Direct Flow) */}
{availabilityStatus !== 'Hired' && ( {availabilityStatus === 'Pending Confirmation' ? (
<div className="bg-amber-50 text-amber-700 border border-amber-200 px-6 py-3 rounded-xl font-bold text-sm flex items-center justify-center space-x-2">
<Clock className="w-4 h-4 text-amber-600 animate-pulse" />
<span>{t('pending_confirmation', 'Pending Confirmation')}</span>
</div>
) : availabilityStatus !== 'Hired' && (
<button <button
type="button" type="button"
onClick={() => setShowHireConfirmModal(true)} onClick={() => setShowHireConfirmModal(true)}
@ -885,41 +891,65 @@ export default function Show({ worker }) {
<div className="fixed inset-0 bg-slate-900/60 backdrop-blur-xs flex items-center justify-center p-4 z-50 animate-fade-in"> <div className="fixed inset-0 bg-slate-900/60 backdrop-blur-xs flex items-center justify-center p-4 z-50 animate-fade-in">
<div className="bg-white rounded-3xl w-full max-w-md p-6 relative animate-zoom-in space-y-6 border border-slate-200 shadow-2xl"> <div className="bg-white rounded-3xl w-full max-w-md p-6 relative animate-zoom-in space-y-6 border border-slate-200 shadow-2xl">
<div className="text-center space-y-3"> {availabilityStatus === 'Pending Confirmation' ? (
<div className="w-16 h-16 bg-emerald-50 text-emerald-600 rounded-full flex items-center justify-center mx-auto shadow-inner"> <>
<CheckCircle2 className="w-8 h-8" /> <div className="text-center space-y-3">
</div> <div className="w-16 h-16 bg-amber-50 text-amber-600 rounded-full flex items-center justify-center mx-auto shadow-inner">
<h4 className="font-extrabold text-lg text-slate-900">{t('direct_sponsoring_finalized', 'Direct Sponsoring Finalized!')}</h4> <Clock className="w-8 h-8" />
<p className="text-xs text-slate-500 leading-relaxed"> </div>
{t('hired_under_sponsorship_desc', '{name} is successfully marked as Hired under your sponsorship! This direct matching conforms exactly with Stage 4 of our UAE MOHRE zero-middlemen flow.').replace('{name}', worker.name)} <h4 className="font-extrabold text-lg text-slate-900">{t('hire_request_sent_title', 'Hiring Request Sent')}</h4>
</p> <p className="text-xs text-slate-500 leading-relaxed">
</div> {t('hired_under_sponsorship_pending_desc', 'Your hiring request has been sent to {name}. The worker must accept the offer to finalize the placement.').replace('{name}', worker.name)}
</p>
</div>
<div className="flex flex-col space-y-2 text-xs font-bold">
<button
onClick={() => setShowHiredModal(false)}
className="w-full bg-[#185FA5] hover:bg-[#144f8a] text-white py-3 rounded-xl flex items-center justify-center"
>
<span>{t('ok', 'OK')}</span>
</button>
</div>
</>
) : (
<>
<div className="text-center space-y-3">
<div className="w-16 h-16 bg-emerald-50 text-emerald-600 rounded-full flex items-center justify-center mx-auto shadow-inner">
<CheckCircle2 className="w-8 h-8" />
</div>
<h4 className="font-extrabold text-lg text-slate-900">{t('direct_sponsoring_finalized', 'Direct Sponsoring Finalized!')}</h4>
<p className="text-xs text-slate-500 leading-relaxed">
{t('hired_under_sponsorship_desc', '{name} is successfully marked as Hired under your sponsorship! This direct matching conforms exactly with Stage 4 of our UAE MOHRE zero-middlemen flow.').replace('{name}', worker.name)}
</p>
</div>
<div className="p-4 bg-blue-50/50 rounded-2xl border border-blue-100/50 space-y-2"> <div className="p-4 bg-blue-50/50 rounded-2xl border border-blue-100/50 space-y-2">
<span className="text-[9px] font-black text-[#185FA5] uppercase tracking-widest block">{t('next_step_post_hire_review', 'Next Step: Stage 5 Post-Hire Review')}</span> <span className="text-[9px] font-black text-[#185FA5] uppercase tracking-widest block">{t('next_step_post_hire_review', 'Next Step: Stage 5 Post-Hire Review')}</span>
<p className="text-[11px] text-slate-700 leading-relaxed font-bold"> <p className="text-[11px] text-slate-700 leading-relaxed font-bold">
{t('help_community_post_review', "Help the UAE community hire safely by posting an anonymous review describing {name}'s childcare, cooking, driving, or domestic skills.").replace('{name}', worker.name)} {t('help_community_post_review', "Help the UAE community hire safely by posting an anonymous review describing {name}'s childcare, cooking, driving, or domestic skills.").replace('{name}', worker.name)}
</p> </p>
</div> </div>
<div className="flex flex-col space-y-2 text-xs font-bold"> <div className="flex flex-col space-y-2 text-xs font-bold">
<button <button
onClick={() => { onClick={() => {
setShowHiredModal(false); setShowHiredModal(false);
setShowReviewModal(true); setShowReviewModal(true);
}} }}
className="w-full bg-[#185FA5] hover:bg-[#144f8a] text-white py-3 rounded-xl flex items-center justify-center space-x-2" className="w-full bg-[#185FA5] hover:bg-[#144f8a] text-white py-3 rounded-xl flex items-center justify-center space-x-2"
> >
<Star className="w-4 h-4 fill-white" /> <Star className="w-4 h-4 fill-white" />
<span>{t('leave_trust_review', 'Leave a Trust Review')}</span> <span>{t('leave_trust_review', 'Leave a Trust Review')}</span>
</button> </button>
<Link <Link
href="/employer/workers" href="/employer/workers"
className="w-full bg-slate-50 border border-slate-200 hover:bg-slate-100 text-slate-700 py-3 rounded-xl flex items-center justify-center" className="w-full bg-slate-50 border border-slate-200 hover:bg-slate-100 text-slate-700 py-3 rounded-xl flex items-center justify-center"
> >
{t('back_to_worker_directory', 'Back to Worker Directory')} {t('back_to_worker_directory', 'Back to Worker Directory')}
</Link> </Link>
</div> </div>
</>
)}
</div> </div>
</div> </div>
)} )}

View File

@ -115,6 +115,8 @@
Route::get('/workers/applied-jobs', [\App\Http\Controllers\Api\WorkerJobController::class, 'appliedJobs']); Route::get('/workers/applied-jobs', [\App\Http\Controllers\Api\WorkerJobController::class, 'appliedJobs']);
Route::get('/workers/applied-jobs/{id}', [\App\Http\Controllers\Api\WorkerJobController::class, 'appliedJobDetail']); Route::get('/workers/applied-jobs/{id}', [\App\Http\Controllers\Api\WorkerJobController::class, 'appliedJobDetail']);
Route::post('/workers/applied-jobs/{id}/withdraw', [\App\Http\Controllers\Api\WorkerJobController::class, 'withdrawApplication']); Route::post('/workers/applied-jobs/{id}/withdraw', [\App\Http\Controllers\Api\WorkerJobController::class, 'withdrawApplication']);
Route::post('/workers/applied-jobs/{id}/confirm-hire', [\App\Http\Controllers\Api\WorkerJobController::class, 'confirmHire']);
Route::post('/workers/applied-jobs/{id}/decline-hire', [\App\Http\Controllers\Api\WorkerJobController::class, 'declineHire']);
// Review Management (Worker reviewing Employer) // Review Management (Worker reviewing Employer)
Route::get('/workers/reviews', [\App\Http\Controllers\Api\WorkerReviewController::class, 'getReviews']); Route::get('/workers/reviews', [\App\Http\Controllers\Api\WorkerReviewController::class, 'getReviews']);

View File

@ -107,11 +107,19 @@ public function test_employer_can_view_jobs_list()
*/ */
public function test_employer_can_view_create_job_form() public function test_employer_can_view_create_job_form()
{ {
// Seed a custom skill in the database
\App\Models\Skill::create(['name' => 'painting']);
$response = $this->actingAs($this->employer) $response = $this->actingAs($this->employer)
->withSession(['user' => $this->employer]) ->withSession(['user' => $this->employer])
->get('/employer/jobs/create'); ->get('/employer/jobs/create');
$response->assertStatus(200); $response->assertStatus(200);
$inertiaData = $response->original->getData();
$professions = $inertiaData['page']['props']['professions'] ?? [];
$this->assertContains('Painting', $professions);
} }
/** /**
@ -123,6 +131,8 @@ public function test_employer_can_post_job()
->withSession(['user' => $this->employer]) ->withSession(['user' => $this->employer])
->post('/employer/jobs/create', [ ->post('/employer/jobs/create', [
'title' => 'Villa Cleaner', 'title' => 'Villa Cleaner',
'main_profession' => 'Cleaner',
'skills' => 'Housekeeping',
'workers_needed' => 2, 'workers_needed' => 2,
'job_type' => 'Part Time', 'job_type' => 'Part Time',
'location' => 'Al Barsha', 'location' => 'Al Barsha',
@ -138,6 +148,7 @@ public function test_employer_can_post_job()
$this->assertDatabaseHas('job_posts', [ $this->assertDatabaseHas('job_posts', [
'employer_id' => $this->employer->id, 'employer_id' => $this->employer->id,
'title' => 'Villa Cleaner', 'title' => 'Villa Cleaner',
'main_profession' => 'Cleaner',
'workers_needed' => 2, 'workers_needed' => 2,
'job_type' => 'Part Time', 'job_type' => 'Part Time',
'location' => 'Al Barsha', 'location' => 'Al Barsha',
@ -158,7 +169,7 @@ public function test_job_posting_requires_fields()
'salary' => -100, 'salary' => -100,
]); ]);
$response->assertSessionHasErrors(['title', 'workers_needed', 'location', 'salary', 'job_type', 'start_date', 'description']); $response->assertSessionHasErrors(['title', 'main_profession', 'skills', 'workers_needed', 'location', 'salary', 'job_type', 'start_date', 'description']);
} }
/** /**
@ -292,4 +303,63 @@ public function test_employer_can_view_shortlisted_applicants()
$response->assertStatus(200); $response->assertStatus(200);
$response->assertSee('Shortlisted John'); $response->assertSee('Shortlisted John');
} }
/**
* Test employer cannot edit a job posting that has active applications.
*/
public function test_employer_cannot_edit_job_with_applications()
{
$job = JobPost::create([
'employer_id' => $this->employer->id,
'title' => 'Job to Edit',
'main_profession' => 'Cleaner',
'workers_needed' => 1,
'job_type' => 'Full Time',
'location' => 'Dubai',
'salary' => 2000,
'start_date' => '2026-07-20',
'description' => 'Will have applications.',
'status' => 'active',
]);
$worker = Worker::create([
'name' => 'Applicant Worker',
'email' => 'worker2@example.com',
'phone' => '+971500000003',
'nationality' => 'Indian',
'age' => 28,
'salary' => 2200,
'availability' => 'Available',
'experience' => '3 Years',
'religion' => 'Christian',
'bio' => 'Ready to clean.',
]);
JobApplication::create([
'job_id' => $job->id,
'worker_id' => $worker->id,
'status' => 'applied',
]);
$response = $this->actingAs($this->employer)
->withSession(['user' => $this->employer])
->post("/employer/jobs/{$job->id}/edit", [
'title' => 'Trying to Edit Title',
'main_profession' => 'Cleaner',
'skills' => 'Housekeeping',
'workers_needed' => 1,
'location' => 'Dubai',
'salary' => 2500,
'job_type' => 'Full Time',
'start_date' => '2026-07-20',
'description' => 'Will have applications.',
'status' => 'active',
]);
$response->assertSessionHasErrors(['general']);
$this->assertDatabaseHas('job_posts', [
'id' => $job->id,
'title' => 'Job to Edit', // should NOT change
]);
}
} }

View File

@ -499,6 +499,12 @@ public function test_worker_hired_via_job_application_appears_in_employers_list(
$response->assertStatus(200); $response->assertStatus(200);
// Worker confirms the hire
$response = $this->postJson("/api/workers/applied-jobs/{$application->id}/confirm-hire", [], [
'Authorization' => 'Bearer worker-hired-token'
]);
$response->assertStatus(200);
// Verify that the JobOffer with accepted status has been created // Verify that the JobOffer with accepted status has been created
$this->assertDatabaseHas('job_offers', [ $this->assertDatabaseHas('job_offers', [
'employer_id' => $employer->id, 'employer_id' => $employer->id,

View File

@ -62,7 +62,11 @@ protected function setUp(): void
'religion' => 'Christian', 'religion' => 'Christian',
'bio' => 'Helper info', 'bio' => 'Helper info',
'passport_status' => 'valid', 'passport_status' => 'valid',
'main_profession' => 'Plumber',
]); ]);
$skill = \App\Models\Skill::firstOrCreate(['name' => 'Piping']);
$this->worker->skills()->sync([$skill->id]);
} }
public function test_employer_job_crud_web() public function test_employer_job_crud_web()
@ -72,6 +76,8 @@ public function test_employer_job_crud_web()
->withSession(['user' => $this->employer]) ->withSession(['user' => $this->employer])
->post('/employer/jobs/create', [ ->post('/employer/jobs/create', [
'title' => 'Test Mason Job', 'title' => 'Test Mason Job',
'main_profession' => 'Plumber',
'skills' => 'Piping',
'workers_needed' => 3, 'workers_needed' => 3,
'location' => 'Dubai', 'location' => 'Dubai',
'salary' => 2000, 'salary' => 2000,
@ -103,6 +109,8 @@ public function test_employer_job_crud_web()
->withSession(['user' => $this->employer]) ->withSession(['user' => $this->employer])
->post("/employer/jobs/{$job->id}/edit", [ ->post("/employer/jobs/{$job->id}/edit", [
'title' => 'Updated Mason Job', 'title' => 'Updated Mason Job',
'main_profession' => 'Plumber',
'skills' => 'Piping',
'workers_needed' => 2, 'workers_needed' => 2,
'location' => 'Abu Dhabi', 'location' => 'Abu Dhabi',
'salary' => 2500, 'salary' => 2500,
@ -137,6 +145,7 @@ public function test_worker_job_apis()
$job = JobPost::create([ $job = JobPost::create([
'employer_id' => $this->employer->id, 'employer_id' => $this->employer->id,
'title' => 'Available Plumber Job', 'title' => 'Available Plumber Job',
'main_profession' => 'Plumber',
'location' => 'Dubai Marina', 'location' => 'Dubai Marina',
'salary' => 3000, 'salary' => 3000,
'workers_needed' => 1, 'workers_needed' => 1,
@ -145,6 +154,8 @@ public function test_worker_job_apis()
'description' => 'Plumber description', 'description' => 'Plumber description',
'status' => 'active', 'status' => 'active',
]); ]);
$skill = \App\Models\Skill::firstOrCreate(['name' => 'Piping']);
$job->skills()->sync([$skill->id]);
// 1. List available jobs // 1. List available jobs
$response = $this->getJson('/api/workers/jobs', [ $response = $this->getJson('/api/workers/jobs', [
@ -233,6 +244,8 @@ public function test_employer_job_apis_and_access_restrictions()
// 2. Create job via API // 2. Create job via API
$response = $this->postJson('/api/employers/jobs', [ $response = $this->postJson('/api/employers/jobs', [
'title' => 'API Clean Job', 'title' => 'API Clean Job',
'main_profession' => 'Plumber',
'skills' => 'Piping',
'workers_needed' => 3, 'workers_needed' => 3,
'location' => 'Dubai JLT', 'location' => 'Dubai JLT',
'salary' => 2200, 'salary' => 2200,
@ -263,6 +276,8 @@ public function test_employer_job_apis_and_access_restrictions()
// 4. Update job via API // 4. Update job via API
$response = $this->postJson("/api/employers/jobs/{$jobId}/update", [ $response = $this->postJson("/api/employers/jobs/{$jobId}/update", [
'title' => 'Updated API Clean Job', 'title' => 'Updated API Clean Job',
'main_profession' => 'Plumber',
'skills' => 'Piping',
'workers_needed' => 4, 'workers_needed' => 4,
'location' => 'Dubai Marina', 'location' => 'Dubai Marina',
'salary' => 2500, 'salary' => 2500,
@ -523,6 +538,7 @@ public function test_hired_worker_visibility_and_profile_access()
$job = JobPost::create([ $job = JobPost::create([
'employer_id' => $this->employer->id, 'employer_id' => $this->employer->id,
'title' => 'Special Hired Job', 'title' => 'Special Hired Job',
'main_profession' => 'Plumber',
'location' => 'Dubai Jumeirah', 'location' => 'Dubai Jumeirah',
'salary' => 4500, 'salary' => 4500,
'workers_needed' => 1, 'workers_needed' => 1,
@ -531,13 +547,15 @@ public function test_hired_worker_visibility_and_profile_access()
'description' => 'A special job details description', 'description' => 'A special job details description',
'status' => 'active', 'status' => 'active',
]); ]);
$skill = \App\Models\Skill::firstOrCreate(['name' => 'Piping']);
$job->skills()->sync([$skill->id]);
// 2. Worker applies for the job // 2. Worker applies for the job
$response = $this->postJson("/api/workers/jobs/{$job->id}/apply", [], [ $response = $this->postJson("/api/workers/jobs/{$job->id}/apply", [], [
'Authorization' => 'Bearer worker_api_token' 'Authorization' => 'Bearer worker_api_token'
]); ]);
$response->assertStatus(201); $response->assertStatus(201);
$app = JobApplication::first(); $app = JobApplication::latest()->first();
// 3. Employer hires the worker by setting status to 'hired' (lowercase, as in the database) // 3. Employer hires the worker by setting status to 'hired' (lowercase, as in the database)
$response = $this->postJson("/api/employers/applications/{$app->id}/status", [ $response = $this->postJson("/api/employers/applications/{$app->id}/status", [
@ -548,6 +566,18 @@ public function test_hired_worker_visibility_and_profile_access()
]); ]);
$response->assertStatus(200); $response->assertStatus(200);
// Under two-way confirmation, it should be in hire_requested status
$this->assertDatabaseHas('job_applications', [
'id' => $app->id,
'status' => 'hire_requested',
]);
// Worker confirms the hiring
$response = $this->postJson("/api/workers/applied-jobs/{$app->id}/confirm-hire", [], [
'Authorization' => 'Bearer worker_api_token'
]);
$response->assertStatus(200);
// Verify status updates // Verify status updates
$this->assertDatabaseHas('job_applications', [ $this->assertDatabaseHas('job_applications', [
'id' => $app->id, 'id' => $app->id,
@ -604,4 +634,100 @@ public function test_hired_worker_visibility_and_profile_access()
$response->assertJsonPath('data.current_employer.id', $this->employer->id); $response->assertJsonPath('data.current_employer.id', $this->employer->id);
$response->assertJsonPath('data.currently_working_sponsor.id', $this->employer->id); $response->assertJsonPath('data.currently_working_sponsor.id', $this->employer->id);
} }
public function test_worker_can_decline_hire()
{
// 1. Create a job posting matching the worker's profession/skills
$job = JobPost::create([
'employer_id' => $this->employer->id,
'title' => 'Housekeeper/Nanny',
'main_profession' => 'Housekeeper',
'location' => 'Dubai Marina',
'salary' => 2500,
'workers_needed' => 1,
'job_type' => 'Full Time',
'start_date' => now()->addDays(5)->format('Y-m-d'),
'description' => 'Need clean house and nanny care.',
'status' => 'active',
]);
$skill = \App\Models\Skill::firstOrCreate(['name' => 'Piping']);
$job->skills()->sync([$skill->id]);
// 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::latest()->first();
// 3. Employer hires the worker by setting status to 'hired'
$response = $this->postJson("/api/employers/applications/{$app->id}/status", [
'status' => 'hired',
'notes' => 'Declining test offer'
], [
'Authorization' => 'Bearer employer_api_token'
]);
$response->assertStatus(200);
// Under two-way confirmation, it should be in hire_requested status
$this->assertDatabaseHas('job_applications', [
'id' => $app->id,
'status' => 'hire_requested',
]);
// Worker declines the hiring
$response = $this->postJson("/api/workers/applied-jobs/{$app->id}/decline-hire", [], [
'Authorization' => 'Bearer worker_api_token'
]);
$response->assertStatus(200);
// Verify status updates
$this->assertDatabaseHas('job_applications', [
'id' => $app->id,
'status' => 'declined',
]);
$this->assertDatabaseHas('workers', [
'id' => $this->worker->id,
'status' => 'active', // Should still be active
]);
}
public function test_worker_can_view_job_matching_only_skills()
{
// 1. Create a job where main_profession is different from worker's (Plumber)
// Let's set it to 'Electrician'
$job = JobPost::create([
'employer_id' => $this->employer->id,
'title' => 'Electrician Needed',
'main_profession' => 'Electrician',
'location' => 'Dubai Marina',
'salary' => 3000,
'workers_needed' => 1,
'job_type' => 'Contract',
'start_date' => now()->addDays(2),
'description' => 'Electrician description',
'status' => 'active',
]);
// Sync the same skill 'Piping' (which the worker has)
$skill = \App\Models\Skill::firstOrCreate(['name' => 'Piping']);
$job->skills()->sync([$skill->id]);
// 2. Request list jobs
$response = $this->getJson('/api/workers/jobs', [
'Authorization' => 'Bearer worker_api_token'
]);
$response->assertStatus(200);
$response->assertJsonPath('success', true);
// The worker's main profession is 'Plumber', but the job's main profession is 'Electrician'.
// However, the worker has the 'Piping' skill, which matches the job.
// Therefore, the job should show up.
$response->assertJsonFragment([
'title' => 'Electrician Needed'
]);
}
} }