job sent for worker job list
This commit is contained in:
parent
e475a7558b
commit
88e4f3fdb4
@ -132,7 +132,7 @@ public function updateProfile(Request $request, $id)
|
||||
'experience' => 'required|string',
|
||||
'salary' => 'nullable|numeric',
|
||||
'nationality' => 'nullable|string',
|
||||
'visa_status' => 'nullable|string|in:Tourist Visa,Employment Visa,Residence Visa',
|
||||
'visa_status' => 'nullable|string',
|
||||
'age' => 'nullable|integer',
|
||||
'in_country' => 'nullable',
|
||||
'preferred_job_type' => 'nullable|string',
|
||||
|
||||
@ -20,7 +20,7 @@ class EmployerWorkerController extends Controller
|
||||
/**
|
||||
* Helper to map worker DB models to API presentation format.
|
||||
*/
|
||||
private function formatWorker(Worker $w)
|
||||
public function formatWorker(Worker $w)
|
||||
{
|
||||
// Map languages from database comma-separated string
|
||||
$langs = $w->language ? array_map('trim', explode(',', $w->language)) : ['English'];
|
||||
@ -777,11 +777,8 @@ public function hireCandidate(Request $request, $id = null)
|
||||
$offerId = substr($targetId, 6);
|
||||
$offer = JobOffer::where('id', $offerId)->where('employer_id', $employerId)->firstOrFail();
|
||||
|
||||
$offer->update(['status' => 'accepted']);
|
||||
if ($offer->worker) {
|
||||
$offer->worker->update(['status' => 'Hired']);
|
||||
$offer->update(['status' => 'pending']);
|
||||
$worker = $offer->worker;
|
||||
}
|
||||
$updatedOffer = $offer;
|
||||
} else {
|
||||
// Check if targetId is an application
|
||||
@ -791,26 +788,9 @@ public function hireCandidate(Request $request, $id = null)
|
||||
})->first();
|
||||
|
||||
if ($application) {
|
||||
$application->update(['status' => 'hired']);
|
||||
if ($application->worker) {
|
||||
$application->worker->update(['status' => 'Hired']);
|
||||
$application->update(['status' => 'hire_requested']);
|
||||
$worker = $application->worker;
|
||||
}
|
||||
$updatedApplication = $application;
|
||||
// Create/update the JobOffer record to satisfy the Worker Employer List API
|
||||
JobOffer::updateOrCreate(
|
||||
[
|
||||
'employer_id' => $employerId,
|
||||
'worker_id' => $application->worker_id,
|
||||
],
|
||||
[
|
||||
'work_date' => $application->jobPost->start_date ?? now(),
|
||||
'location' => $application->jobPost->location ?? 'Dubai',
|
||||
'salary' => $application->jobPost->salary ?? 0,
|
||||
'status' => 'accepted',
|
||||
'notes' => $application->notes ?? 'Hired through Job Application',
|
||||
]
|
||||
);
|
||||
} else {
|
||||
// Try targeting by Worker ID directly
|
||||
$worker = Worker::find($targetId);
|
||||
@ -822,15 +802,13 @@ public function hireCandidate(Request $request, $id = null)
|
||||
], 404);
|
||||
}
|
||||
|
||||
$worker->update(['status' => 'Hired']);
|
||||
|
||||
// Find existing direct offer
|
||||
// Find existing direct offer or create a new one with status 'pending'
|
||||
$offer = JobOffer::where('employer_id', $employerId)
|
||||
->where('worker_id', $worker->id)
|
||||
->first();
|
||||
|
||||
if ($offer) {
|
||||
$offer->update(['status' => 'accepted']);
|
||||
$offer->update(['status' => 'pending']);
|
||||
$updatedOffer = $offer;
|
||||
} else {
|
||||
// Find existing application
|
||||
@ -840,57 +818,57 @@ public function hireCandidate(Request $request, $id = null)
|
||||
->first();
|
||||
|
||||
if ($application) {
|
||||
$application->update(['status' => 'hired']);
|
||||
$application->update(['status' => 'hire_requested']);
|
||||
$updatedApplication = $application;
|
||||
// Create/update the JobOffer record to satisfy the Worker Employer List API
|
||||
JobOffer::updateOrCreate(
|
||||
[
|
||||
'employer_id' => $employerId,
|
||||
'worker_id' => $application->worker_id,
|
||||
],
|
||||
[
|
||||
'work_date' => $application->jobPost->start_date ?? now(),
|
||||
'location' => $application->jobPost->location ?? 'Dubai',
|
||||
'salary' => $application->jobPost->salary ?? 0,
|
||||
'status' => 'accepted',
|
||||
'notes' => $application->notes ?? 'Hired through Job Application',
|
||||
]
|
||||
);
|
||||
} else {
|
||||
// Create a new direct job offer and accept it
|
||||
// Create a new direct job offer in 'pending' status
|
||||
$updatedOffer = JobOffer::create([
|
||||
'employer_id' => $employerId,
|
||||
'worker_id' => $worker->id,
|
||||
'work_date' => now()->format('Y-m-d'),
|
||||
'location' => 'Dubai',
|
||||
'salary' => $worker->salary,
|
||||
'notes' => 'Direct sponsoring matching finalized via mobile API.',
|
||||
'status' => 'accepted',
|
||||
'notes' => 'Direct sponsoring matching initiated via API.',
|
||||
'status' => 'pending',
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dispatch push notification to worker
|
||||
if ($worker && $worker->fcm_token) {
|
||||
\App\Services\FCMService::sendPushNotification(
|
||||
$worker->fcm_token,
|
||||
"Congratulations! You've been Hired",
|
||||
"Employer " . ($employer->name ?? "Employer") . " has hired you.",
|
||||
// Dispatch push & database notification to worker for confirmation request
|
||||
if ($worker) {
|
||||
if ($updatedApplication) {
|
||||
$job = $updatedApplication->jobPost;
|
||||
$worker->notify(new \App\Notifications\GenericNotification(
|
||||
"Action Required: Confirm Hiring",
|
||||
"Employer " . ($employer->name ?? "Employer") . " wants to hire you for '" . ($job->title ?? 'Job Post') . "'. Please confirm.",
|
||||
[
|
||||
'type' => '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([
|
||||
'success' => true,
|
||||
'message' => 'Candidate successfully marked as Hired!',
|
||||
'message' => 'Hire request initiated successfully. Awaiting candidate confirmation.',
|
||||
'data' => [
|
||||
'worker_id' => $worker ? $worker->id : null,
|
||||
'worker_status' => $worker ? $worker->status : 'Hired',
|
||||
'worker_status' => $worker ? $worker->status : 'Active',
|
||||
'application' => $updatedApplication,
|
||||
'offer' => $updatedOffer,
|
||||
]
|
||||
|
||||
@ -318,7 +318,7 @@ public function register(Request $request)
|
||||
'age' => 'nullable|integer',
|
||||
'experience' => 'nullable|string|max:100',
|
||||
'in_country' => 'nullable',
|
||||
'visa_status' => 'nullable|string|in:Tourist Visa,Employment Visa,Residence Visa',
|
||||
'visa_status' => 'nullable|string',
|
||||
'preferred_job_type' => 'nullable|string|max:100',
|
||||
'main_profession' => 'nullable|string|max:100',
|
||||
'gender' => 'nullable|string|in:male,female,other',
|
||||
|
||||
@ -18,14 +18,38 @@ class WorkerJobController extends Controller
|
||||
*/
|
||||
public function listJobs(Request $request)
|
||||
{
|
||||
$jobs = JobPost::where('status', 'active')
|
||||
->with(['employer.employerProfile'])
|
||||
->latest()
|
||||
/** @var Worker $worker */
|
||||
$worker = $request->attributes->get('worker');
|
||||
|
||||
$query = JobPost::where('status', 'active')
|
||||
->with(['employer.employerProfile', 'skills']);
|
||||
|
||||
if ($worker) {
|
||||
$workerProfession = trim($worker->main_profession);
|
||||
$workerSkillIds = $worker->skills->pluck('id')->toArray();
|
||||
|
||||
if ($workerProfession !== '' || !empty($workerSkillIds)) {
|
||||
$query->where(function($q) use ($workerProfession, $workerSkillIds) {
|
||||
if ($workerProfession !== '') {
|
||||
$q->where(\Illuminate\Support\Facades\DB::raw('LOWER(main_profession)'), strtolower($workerProfession));
|
||||
}
|
||||
if (!empty($workerSkillIds)) {
|
||||
$q->orWhereHas('skills', function ($q2) use ($workerSkillIds) {
|
||||
$q2->whereIn('skills.id', $workerSkillIds);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
$jobs = $query->latest()
|
||||
->get()
|
||||
->map(function ($job) {
|
||||
return [
|
||||
'id' => $job->id,
|
||||
'title' => $job->title,
|
||||
'main_profession' => $job->main_profession,
|
||||
'skills' => $job->skills->pluck('name')->toArray(),
|
||||
'location' => $job->location,
|
||||
'salary' => (int) $job->salary,
|
||||
'workers_needed' => $job->workers_needed,
|
||||
@ -54,7 +78,7 @@ public function listJobs(Request $request)
|
||||
public function jobDetails($id)
|
||||
{
|
||||
$job = JobPost::where('status', 'active')
|
||||
->with(['employer.employerProfile'])
|
||||
->with(['employer.employerProfile', 'skills'])
|
||||
->find($id);
|
||||
|
||||
if (!$job) {
|
||||
@ -70,6 +94,8 @@ public function jobDetails($id)
|
||||
'job' => [
|
||||
'id' => $job->id,
|
||||
'title' => $job->title,
|
||||
'main_profession' => $job->main_profession,
|
||||
'skills' => $job->skills->pluck('name')->toArray(),
|
||||
'location' => $job->location,
|
||||
'salary' => (int) $job->salary,
|
||||
'workers_needed' => $job->workers_needed,
|
||||
@ -121,6 +147,19 @@ public function applyJob(Request $request, $id)
|
||||
'status' => 'applied'
|
||||
]);
|
||||
|
||||
$employer = $job->employer;
|
||||
if ($employer) {
|
||||
$employer->notify(new \App\Notifications\GenericNotification(
|
||||
"New Job Application",
|
||||
"A worker (" . ($worker->name ?? 'Candidate') . ") has applied for your job: " . ($job->title ?? 'Job Post'),
|
||||
[
|
||||
'type' => 'job_application_submitted',
|
||||
'job_id' => $job->id,
|
||||
'application_id' => $application->id,
|
||||
]
|
||||
));
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Successfully applied for the job.',
|
||||
@ -244,6 +283,8 @@ public function employerCreateJob(Request $request)
|
||||
|
||||
$validator = Validator::make($request->all(), [
|
||||
'title' => 'required|string|max:255',
|
||||
'main_profession' => 'required|string|max:255',
|
||||
'skills' => 'required',
|
||||
'workers_needed' => 'required|integer|min:1',
|
||||
'location' => 'required|string|max:255',
|
||||
'salary' => 'required|numeric|min:0',
|
||||
@ -264,6 +305,7 @@ public function employerCreateJob(Request $request)
|
||||
$job = JobPost::create([
|
||||
'employer_id' => $employer->id,
|
||||
'title' => $request->title,
|
||||
'main_profession' => $request->main_profession,
|
||||
'workers_needed' => $request->workers_needed,
|
||||
'job_type' => $request->job_type,
|
||||
'location' => $request->location,
|
||||
@ -274,6 +316,27 @@ public function employerCreateJob(Request $request)
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
// Sync skills
|
||||
$skillsInput = $request->skills;
|
||||
$skillIds = [];
|
||||
if (is_array($skillsInput)) {
|
||||
foreach ($skillsInput as $skillNameOrId) {
|
||||
if (is_numeric($skillNameOrId)) {
|
||||
$skillIds[] = (int)$skillNameOrId;
|
||||
} else {
|
||||
$skill = \App\Models\Skill::firstOrCreate(['name' => trim($skillNameOrId)]);
|
||||
$skillIds[] = $skill->id;
|
||||
}
|
||||
}
|
||||
} else if (is_string($skillsInput)) {
|
||||
$skillsArray = array_filter(array_map('trim', explode(',', $skillsInput)));
|
||||
foreach ($skillsArray as $name) {
|
||||
$skill = \App\Models\Skill::firstOrCreate(['name' => $name]);
|
||||
$skillIds[] = $skill->id;
|
||||
}
|
||||
}
|
||||
$job->skills()->sync($skillIds);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Job posted successfully.',
|
||||
@ -281,6 +344,8 @@ public function employerCreateJob(Request $request)
|
||||
'job' => [
|
||||
'id' => $job->id,
|
||||
'title' => $job->title,
|
||||
'main_profession' => $job->main_profession,
|
||||
'skills' => $job->skills()->pluck('name')->toArray(),
|
||||
'location' => $job->location,
|
||||
'salary' => (int) $job->salary,
|
||||
'workers_needed' => $job->workers_needed,
|
||||
@ -368,8 +433,17 @@ public function employerUpdateJob(Request $request, $id)
|
||||
], 404);
|
||||
}
|
||||
|
||||
if ($job->applications()->exists()) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'This job cannot be edited because workers have already applied for it.'
|
||||
], 422);
|
||||
}
|
||||
|
||||
$validator = Validator::make($request->all(), [
|
||||
'title' => 'required|string|max:255',
|
||||
'main_profession' => 'required|string|max:255',
|
||||
'skills' => 'required',
|
||||
'workers_needed' => 'required|integer|min:1',
|
||||
'location' => 'required|string|max:255',
|
||||
'salary' => 'required|numeric|min:0',
|
||||
@ -390,6 +464,7 @@ public function employerUpdateJob(Request $request, $id)
|
||||
|
||||
$job->update([
|
||||
'title' => $request->title,
|
||||
'main_profession' => $request->main_profession,
|
||||
'workers_needed' => $request->workers_needed,
|
||||
'job_type' => $request->job_type,
|
||||
'location' => $request->location,
|
||||
@ -400,6 +475,27 @@ public function employerUpdateJob(Request $request, $id)
|
||||
'status' => strtolower($request->status),
|
||||
]);
|
||||
|
||||
// Sync skills
|
||||
$skillsInput = $request->skills;
|
||||
$skillIds = [];
|
||||
if (is_array($skillsInput)) {
|
||||
foreach ($skillsInput as $skillNameOrId) {
|
||||
if (is_numeric($skillNameOrId)) {
|
||||
$skillIds[] = (int)$skillNameOrId;
|
||||
} else {
|
||||
$skill = \App\Models\Skill::firstOrCreate(['name' => trim($skillNameOrId)]);
|
||||
$skillIds[] = $skill->id;
|
||||
}
|
||||
}
|
||||
} else if (is_string($skillsInput)) {
|
||||
$skillsArray = array_filter(array_map('trim', explode(',', $skillsInput)));
|
||||
foreach ($skillsArray as $name) {
|
||||
$skill = \App\Models\Skill::firstOrCreate(['name' => $name]);
|
||||
$skillIds[] = $skill->id;
|
||||
}
|
||||
}
|
||||
$job->skills()->sync($skillIds);
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Job updated successfully.',
|
||||
@ -407,6 +503,8 @@ public function employerUpdateJob(Request $request, $id)
|
||||
'job' => [
|
||||
'id' => $job->id,
|
||||
'title' => $job->title,
|
||||
'main_profession' => $job->main_profession,
|
||||
'skills' => $job->skills()->pluck('name')->toArray(),
|
||||
'location' => $job->location,
|
||||
'salary' => (int) $job->salary,
|
||||
'workers_needed' => $job->workers_needed,
|
||||
@ -562,7 +660,7 @@ public function employerUpdateApplicationStatus(Request $request, $id)
|
||||
}
|
||||
|
||||
$validator = Validator::make($request->all(), [
|
||||
'status' => 'required|string|in:applied,shortlisted,contacted,interview scheduled,interview_scheduled,selected,rejected,hired',
|
||||
'status' => 'required|string|in:applied,shortlisted,contacted,interview scheduled,interview_scheduled,selected,rejected,hired,hire_requested',
|
||||
'notes' => 'nullable|string',
|
||||
]);
|
||||
|
||||
@ -587,9 +685,12 @@ public function employerUpdateApplicationStatus(Request $request, $id)
|
||||
}
|
||||
|
||||
$dbStatus = strtolower($request->status);
|
||||
if ($dbStatus === 'hired') {
|
||||
$dbStatus = 'hire_requested';
|
||||
}
|
||||
|
||||
// Contact Limit check: if moving to 'shortlisted', 'contacted', 'interview_scheduled', 'selected', 'hired' for the first time
|
||||
$contactStatuses = ['shortlisted', 'contacted', 'interview_scheduled', 'selected', 'hired'];
|
||||
// Contact Limit check: if moving to 'shortlisted', 'contacted', 'interview_scheduled', 'selected', 'hired', 'hire_requested'
|
||||
$contactStatuses = ['shortlisted', 'contacted', 'interview_scheduled', 'selected', 'hired', 'hire_requested'];
|
||||
if (in_array($dbStatus, $contactStatuses)) {
|
||||
if (!User::canContactWorker($employer->id, $application->worker_id)) {
|
||||
$activeSub = \App\Models\Subscription::where('user_id', $employer->id)->where('status', 'active')->first();
|
||||
@ -638,26 +739,7 @@ public function employerUpdateApplicationStatus(Request $request, $id)
|
||||
}
|
||||
}
|
||||
|
||||
// If hired, also update worker status
|
||||
if ($dbStatus === 'hired') {
|
||||
if ($application->worker) {
|
||||
$application->worker->update(['status' => 'Hired']);
|
||||
}
|
||||
// Create/update the JobOffer record to satisfy the Worker Employer List API
|
||||
\App\Models\JobOffer::updateOrCreate(
|
||||
[
|
||||
'employer_id' => $employer->id,
|
||||
'worker_id' => $application->worker_id,
|
||||
],
|
||||
[
|
||||
'work_date' => $application->jobPost->start_date ?? now(),
|
||||
'location' => $application->jobPost->location ?? 'Dubai',
|
||||
'salary' => $application->jobPost->salary ?? 0,
|
||||
'status' => 'accepted',
|
||||
'notes' => $application->notes ?? 'Hired through Job Application',
|
||||
]
|
||||
);
|
||||
} elseif ($dbStatus === 'rejected') {
|
||||
if ($dbStatus === 'rejected') {
|
||||
if ($application->worker) {
|
||||
$application->worker->update(['status' => 'active']);
|
||||
}
|
||||
@ -756,7 +838,164 @@ public function withdrawApplication(Request $request, $id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch FCM Push Notification.
|
||||
* API for workers to confirm a hire request.
|
||||
* POST /api/workers/applied-jobs/{id}/confirm-hire
|
||||
*/
|
||||
public function confirmHire(Request $request, $id)
|
||||
{
|
||||
/** @var Worker $worker */
|
||||
$worker = $request->attributes->get('worker');
|
||||
|
||||
$application = JobApplication::where('worker_id', $worker->id)->find($id);
|
||||
|
||||
if (!$application) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Application not found.'
|
||||
], 404);
|
||||
}
|
||||
|
||||
if (strtolower($application->status) !== 'hire_requested') {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'This application is not pending confirmation.'
|
||||
], 400);
|
||||
}
|
||||
|
||||
// Update application status
|
||||
$history = $application->status_history ?: [];
|
||||
$history[] = [
|
||||
'status' => 'hired',
|
||||
'timestamp' => now()->toIso8601String(),
|
||||
'notes' => 'Hiring confirmed by worker.',
|
||||
];
|
||||
|
||||
$application->update([
|
||||
'status' => 'hired',
|
||||
'status_history' => $history,
|
||||
'joining_confirmed_at' => now(),
|
||||
]);
|
||||
|
||||
// Update worker status
|
||||
$worker->update([
|
||||
'status' => 'Hired',
|
||||
'availability' => 'Hired'
|
||||
]);
|
||||
|
||||
// Create/update the JobOffer record to satisfy the Worker Employer List API
|
||||
$job = $application->jobPost;
|
||||
$employer = $job ? $job->employer : null;
|
||||
if ($employer) {
|
||||
\App\Models\JobOffer::updateOrCreate(
|
||||
[
|
||||
'employer_id' => $employer->id,
|
||||
'worker_id' => $worker->id,
|
||||
],
|
||||
[
|
||||
'work_date' => $job->start_date ?? now(),
|
||||
'location' => $job->location ?? 'Dubai',
|
||||
'salary' => $job->salary ?? 0,
|
||||
'status' => 'accepted',
|
||||
'notes' => $application->notes ?? 'Hired through Job Application',
|
||||
]
|
||||
);
|
||||
|
||||
// Notify Employer: Hire confirmation
|
||||
$employer->notify(new \App\Notifications\GenericNotification(
|
||||
"Hire Confirmation",
|
||||
"Worker " . ($worker->name ?? "Candidate") . " has confirmed the hiring for '" . ($job->title ?? 'Job Post') . "'.",
|
||||
[
|
||||
'type' => 'hire_confirmation',
|
||||
'job_id' => $job->id,
|
||||
'application_id' => $application->id,
|
||||
]
|
||||
));
|
||||
}
|
||||
|
||||
// Notify Worker: Hired
|
||||
$worker->notify(new \App\Notifications\GenericNotification(
|
||||
"Congratulations! You've been Hired",
|
||||
"You are now officially hired for: '" . ($job->title ?? 'Job Post') . "'.",
|
||||
[
|
||||
'type' => 'hired',
|
||||
'job_id' => $job->id,
|
||||
'application_id' => $application->id,
|
||||
]
|
||||
));
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Hiring confirmed successfully.',
|
||||
'data' => [
|
||||
'application' => $application->fresh()
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* API for workers to decline a hire request.
|
||||
* POST /api/workers/applied-jobs/{id}/decline-hire
|
||||
*/
|
||||
public function declineHire(Request $request, $id)
|
||||
{
|
||||
/** @var Worker $worker */
|
||||
$worker = $request->attributes->get('worker');
|
||||
|
||||
$application = JobApplication::where('worker_id', $worker->id)->find($id);
|
||||
|
||||
if (!$application) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Application not found.'
|
||||
], 404);
|
||||
}
|
||||
|
||||
if (strtolower($application->status) !== 'hire_requested') {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'This application is not pending confirmation.'
|
||||
], 400);
|
||||
}
|
||||
|
||||
// Update application status back to declined
|
||||
$history = $application->status_history ?: [];
|
||||
$history[] = [
|
||||
'status' => 'declined',
|
||||
'timestamp' => now()->toIso8601String(),
|
||||
'notes' => 'Hiring declined by worker.',
|
||||
];
|
||||
|
||||
$application->update([
|
||||
'status' => 'declined',
|
||||
'status_history' => $history,
|
||||
]);
|
||||
|
||||
$job = $application->jobPost;
|
||||
$employer = $job ? $job->employer : null;
|
||||
if ($employer) {
|
||||
// Notify Employer: Hire declined
|
||||
$employer->notify(new \App\Notifications\GenericNotification(
|
||||
"Hire Request Declined",
|
||||
"Worker " . ($worker->name ?? "Candidate") . " has declined the hiring request for '" . ($job->title ?? 'Job Post') . "'.",
|
||||
[
|
||||
'type' => 'hire_declined',
|
||||
'job_id' => $job->id,
|
||||
'application_id' => $application->id,
|
||||
]
|
||||
));
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'message' => 'Hiring request declined successfully.',
|
||||
'data' => [
|
||||
'application' => $application->fresh()
|
||||
]
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch FCM Push Notification & In-app database notifications.
|
||||
*/
|
||||
private function triggerStatusNotification($application, $status)
|
||||
{
|
||||
@ -766,61 +1005,76 @@ private function triggerStatusNotification($application, $status)
|
||||
|
||||
// When a new application is submitted: notify employer
|
||||
if ($status === 'applied') {
|
||||
if ($employer && $employer->fcm_token) {
|
||||
\App\Services\FCMService::sendPushNotification(
|
||||
$employer->fcm_token,
|
||||
if ($employer) {
|
||||
$employer->notify(new \App\Notifications\GenericNotification(
|
||||
"New Job Application",
|
||||
"A candidate has applied for your job: " . ($job->title ?? 'Job Post'),
|
||||
[
|
||||
'type' => 'new_job_application',
|
||||
'type' => 'job_application_submitted',
|
||||
'job_id' => $job->id,
|
||||
'application_id' => $application->id,
|
||||
]
|
||||
);
|
||||
));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// When status is updated: notify worker
|
||||
if ($worker && $worker->fcm_token) {
|
||||
if ($worker) {
|
||||
$title = "Job Application Update";
|
||||
$body = "";
|
||||
$type = 'job_application_status_update';
|
||||
switch (strtolower($status)) {
|
||||
case 'shortlisted':
|
||||
$body = "You have been shortlisted for the job: " . ($job->title ?? 'Job Post');
|
||||
$type = 'shortlisted';
|
||||
break;
|
||||
case 'review':
|
||||
case 'reviewing':
|
||||
$body = "Your application is under review for the job: " . ($job->title ?? 'Job Post');
|
||||
$type = 'review';
|
||||
break;
|
||||
case 'contacted':
|
||||
$body = "An employer wants to contact you regarding the job: " . ($job->title ?? 'Job Post');
|
||||
$type = 'contacted';
|
||||
break;
|
||||
case 'interview scheduled':
|
||||
case 'interview_scheduled':
|
||||
$body = "An interview has been scheduled for the job: " . ($job->title ?? 'Job Post');
|
||||
$type = 'interview_scheduled';
|
||||
break;
|
||||
case 'selected':
|
||||
$body = "Congratulations! You have been selected for the job: " . ($job->title ?? 'Job Post');
|
||||
$type = 'selected';
|
||||
break;
|
||||
case 'rejected':
|
||||
$body = "Your application for the job: " . ($job->title ?? 'Job Post') . " has been reviewed and rejected.";
|
||||
$type = 'rejected';
|
||||
break;
|
||||
case 'hire_requested':
|
||||
$title = "Action Required: Confirm Hiring";
|
||||
$body = "An employer wants to hire you for the job: " . ($job->title ?? 'Job Post') . ". Please confirm.";
|
||||
$type = 'hire_request';
|
||||
break;
|
||||
case 'hired':
|
||||
$body = "Congratulations! You have been hired for the job: " . ($job->title ?? 'Job Post');
|
||||
$type = 'hired';
|
||||
break;
|
||||
default:
|
||||
$body = "Your application status for the job: " . ($job->title ?? 'Job Post') . " has changed to " . ucfirst($status);
|
||||
break;
|
||||
}
|
||||
|
||||
\App\Services\FCMService::sendPushNotification(
|
||||
$worker->fcm_token,
|
||||
$worker->notify(new \App\Notifications\GenericNotification(
|
||||
$title,
|
||||
$body,
|
||||
[
|
||||
'type' => 'job_application_status_update',
|
||||
'type' => $type,
|
||||
'job_id' => $job->id ?? '',
|
||||
'application_id' => $application->id,
|
||||
'status' => $status,
|
||||
]
|
||||
);
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -78,7 +78,7 @@ public function updateProfile(Request $request)
|
||||
'skills' => 'nullable|array',
|
||||
'skills.*' => 'exists:skills,id',
|
||||
'in_country' => 'nullable',
|
||||
'visa_status' => 'nullable|string|in:Tourist Visa,Employment Visa,Residence Visa',
|
||||
'visa_status' => 'nullable|string',
|
||||
'preferred_job_type' => 'nullable|string|max:100',
|
||||
'gender' => 'nullable|string|in:male,female,other',
|
||||
'live_in_out' => 'nullable|string|in:live_in,live_out',
|
||||
@ -472,10 +472,9 @@ public function respondToOffer(Request $request, $id)
|
||||
|
||||
// Dispatch push notification to employer
|
||||
$employer = $offer->employer;
|
||||
if ($employer && $employer->fcm_token) {
|
||||
if ($employer) {
|
||||
$statusMessage = $responseStatus === 'accepted' ? 'accepted' : 'rejected';
|
||||
\App\Services\FCMService::sendPushNotification(
|
||||
$employer->fcm_token,
|
||||
$employer->notify(new \App\Notifications\GenericNotification(
|
||||
"Job Offer " . ucfirst($statusMessage),
|
||||
"Worker " . ($worker->name ?? "Candidate") . " has " . $statusMessage . " your job offer.",
|
||||
[
|
||||
@ -483,7 +482,7 @@ public function respondToOffer(Request $request, $id)
|
||||
'offer_id' => $offer->id,
|
||||
'status' => $responseStatus,
|
||||
]
|
||||
);
|
||||
));
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
|
||||
@ -316,7 +316,7 @@ private function checkJobAccess($user)
|
||||
public function updateStatus(Request $request, $id)
|
||||
{
|
||||
$request->validate([
|
||||
'status' => 'required|string|in:Applied,Reviewing,Shortlisted,Contacted,Interview Scheduled,Selected,Rejected,Hired,Offer Sent,applied,shortlisted,contacted,interview scheduled,interview_scheduled,selected,rejected,hired,offer sent,offer_sent',
|
||||
'status' => 'required|string|in:Applied,Reviewing,Shortlisted,Contacted,Interview Scheduled,Selected,Rejected,Hired,Offer Sent,applied,shortlisted,contacted,interview scheduled,interview_scheduled,selected,rejected,hired,offer sent,offer_sent,hire_requested,hire_requested',
|
||||
'notes' => 'nullable|string',
|
||||
]);
|
||||
|
||||
@ -327,18 +327,31 @@ public function updateStatus(Request $request, $id)
|
||||
|
||||
$dbStatus = 'pending';
|
||||
if (in_array(strtolower($request->status), ['hired', 'accepted'])) {
|
||||
$dbStatus = 'accepted';
|
||||
$offer->worker->update(['status' => 'Hired']);
|
||||
// Under two-way confirmation, it must NOT immediately set status to accepted/Hired.
|
||||
// It should set offer status to 'pending' (waiting for worker confirmation).
|
||||
$dbStatus = 'pending';
|
||||
} elseif (in_array(strtolower($request->status), ['rejected'])) {
|
||||
$dbStatus = 'rejected';
|
||||
$offer->worker->update(['status' => 'active']);
|
||||
} elseif (in_array(strtolower($request->status), ['offer sent', 'offer_sent', 'pending'])) {
|
||||
$dbStatus = 'pending';
|
||||
}
|
||||
|
||||
$offer->update(['status' => $dbStatus]);
|
||||
|
||||
return back()->with('success', 'Direct candidate hiring offer status updated successfully to ' . $request->status);
|
||||
// Notify worker about the hire offer if it is set to pending
|
||||
if ($dbStatus === 'pending') {
|
||||
$offer->worker->notify(new \App\Notifications\GenericNotification(
|
||||
"Action Required: Confirm Hiring",
|
||||
"Employer " . ($offer->employer->name ?? "Employer") . " has sent you a direct hire offer. Please confirm.",
|
||||
[
|
||||
'type' => 'hire_request',
|
||||
'offer_id' => $offer->id,
|
||||
'status' => 'pending',
|
||||
]
|
||||
));
|
||||
}
|
||||
|
||||
return back()->with('success', 'Hire request initiated successfully. Awaiting candidate confirmation.');
|
||||
}
|
||||
|
||||
// Standard job application status update
|
||||
@ -356,8 +369,8 @@ public function updateStatus(Request $request, $id)
|
||||
|
||||
$dbStatus = 'applied';
|
||||
$statusLower = strtolower($request->status);
|
||||
if ($statusLower === 'hired') {
|
||||
$dbStatus = 'hired';
|
||||
if ($statusLower === 'hired' || $statusLower === 'hire_requested') {
|
||||
$dbStatus = 'hire_requested';
|
||||
} elseif ($statusLower === 'rejected') {
|
||||
$dbStatus = 'rejected';
|
||||
} elseif ($statusLower === 'shortlisted' || $statusLower === 'offer sent' || $statusLower === 'offer_sent') {
|
||||
@ -372,8 +385,8 @@ public function updateStatus(Request $request, $id)
|
||||
$dbStatus = 'applied';
|
||||
}
|
||||
|
||||
// Contact Limit check: if moving to 'shortlisted', 'contacted', 'interview_scheduled', 'selected', 'hired'
|
||||
$contactStatuses = ['shortlisted', 'contacted', 'interview_scheduled', 'selected', 'hired'];
|
||||
// Contact Limit check: if moving to 'shortlisted', 'contacted', 'interview_scheduled', 'selected', 'hired', 'hire_requested'
|
||||
$contactStatuses = ['shortlisted', 'contacted', 'interview_scheduled', 'selected', 'hired', 'hire_requested'];
|
||||
if (in_array($dbStatus, $contactStatuses)) {
|
||||
if (!User::canContactWorker($user->id, $app->worker_id)) {
|
||||
$activeSub = \App\Models\Subscription::where('user_id', $user->id)->where('status', 'active')->first();
|
||||
@ -419,26 +432,7 @@ public function updateStatus(Request $request, $id)
|
||||
}
|
||||
}
|
||||
|
||||
// If hired, also update worker status
|
||||
if ($dbStatus === 'hired') {
|
||||
if ($app->worker) {
|
||||
$app->worker->update(['status' => 'Hired']);
|
||||
}
|
||||
// Create/update the JobOffer record to satisfy the Worker Employer List API
|
||||
JobOffer::updateOrCreate(
|
||||
[
|
||||
'employer_id' => $user->id,
|
||||
'worker_id' => $app->worker_id,
|
||||
],
|
||||
[
|
||||
'work_date' => $app->jobPost->start_date ?? now(),
|
||||
'location' => $app->jobPost->location ?? 'Dubai',
|
||||
'salary' => $app->jobPost->salary ?? 0,
|
||||
'status' => 'accepted',
|
||||
'notes' => $app->notes ?? 'Hired through Job Application',
|
||||
]
|
||||
);
|
||||
} elseif ($dbStatus === 'rejected') {
|
||||
if ($dbStatus === 'rejected') {
|
||||
if ($app->worker) {
|
||||
$app->worker->update(['status' => 'active']);
|
||||
}
|
||||
@ -458,61 +452,76 @@ private function triggerStatusNotification($application, $status)
|
||||
|
||||
// When a new application is submitted: notify employer
|
||||
if ($status === 'applied') {
|
||||
if ($employer && $employer->fcm_token) {
|
||||
\App\Services\FCMService::sendPushNotification(
|
||||
$employer->fcm_token,
|
||||
if ($employer) {
|
||||
$employer->notify(new \App\Notifications\GenericNotification(
|
||||
"New Job Application",
|
||||
"A candidate has applied for your job: " . ($job->title ?? 'Job Post'),
|
||||
[
|
||||
'type' => 'new_job_application',
|
||||
'type' => 'job_application_submitted',
|
||||
'job_id' => $job->id,
|
||||
'application_id' => $application->id,
|
||||
]
|
||||
);
|
||||
));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// When status is updated: notify worker
|
||||
if ($worker && $worker->fcm_token) {
|
||||
if ($worker) {
|
||||
$title = "Job Application Update";
|
||||
$body = "";
|
||||
$type = 'job_application_status_update';
|
||||
switch (strtolower($status)) {
|
||||
case 'shortlisted':
|
||||
$body = "You have been shortlisted for the job: " . ($job->title ?? 'Job Post');
|
||||
$type = 'shortlisted';
|
||||
break;
|
||||
case 'review':
|
||||
case 'reviewing':
|
||||
$body = "Your application is under review for the job: " . ($job->title ?? 'Job Post');
|
||||
$type = 'review';
|
||||
break;
|
||||
case 'contacted':
|
||||
$body = "An employer wants to contact you regarding the job: " . ($job->title ?? 'Job Post');
|
||||
$type = 'contacted';
|
||||
break;
|
||||
case 'interview scheduled':
|
||||
case 'interview_scheduled':
|
||||
$body = "An interview has been scheduled for the job: " . ($job->title ?? 'Job Post');
|
||||
$type = 'interview_scheduled';
|
||||
break;
|
||||
case 'selected':
|
||||
$body = "Congratulations! You have been selected for the job: " . ($job->title ?? 'Job Post');
|
||||
$type = 'selected';
|
||||
break;
|
||||
case 'rejected':
|
||||
$body = "Your application for the job: " . ($job->title ?? 'Job Post') . " has been reviewed and rejected.";
|
||||
$type = 'rejected';
|
||||
break;
|
||||
case 'hire_requested':
|
||||
$title = "Action Required: Confirm Hiring";
|
||||
$body = "An employer wants to hire you for the job: " . ($job->title ?? 'Job Post') . ". Please confirm.";
|
||||
$type = 'hire_request';
|
||||
break;
|
||||
case 'hired':
|
||||
$body = "Congratulations! You have been hired for the job: " . ($job->title ?? 'Job Post');
|
||||
$type = 'hired';
|
||||
break;
|
||||
default:
|
||||
$body = "Your application status for the job: " . ($job->title ?? 'Job Post') . " has changed to " . ucfirst($status);
|
||||
break;
|
||||
}
|
||||
|
||||
\App\Services\FCMService::sendPushNotification(
|
||||
$worker->fcm_token,
|
||||
$worker->notify(new \App\Notifications\GenericNotification(
|
||||
$title,
|
||||
$body,
|
||||
[
|
||||
'type' => 'job_application_status_update',
|
||||
'type' => $type,
|
||||
'job_id' => $job->id ?? '',
|
||||
'application_id' => $application->id,
|
||||
'status' => $status,
|
||||
]
|
||||
);
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -139,7 +139,32 @@ public function create()
|
||||
->with('error', 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.');
|
||||
}
|
||||
|
||||
return Inertia::render('Employer/Jobs/Create');
|
||||
$professions = \App\Models\Skill::orderBy('name')
|
||||
->pluck('name')
|
||||
->map(function ($name) {
|
||||
return ucwords($name);
|
||||
})
|
||||
->unique()
|
||||
->values()
|
||||
->toArray();
|
||||
|
||||
if (empty($professions)) {
|
||||
$professions = [
|
||||
'Housekeeper', 'Nanny', 'Maid', 'Electrician', 'Mason', 'Plumber', 'Cleaner', 'Driver', 'Caregiver', 'Cook'
|
||||
];
|
||||
}
|
||||
|
||||
$skills = \App\Models\Skill::all()->map(function ($skill) {
|
||||
return [
|
||||
'id' => $skill->id,
|
||||
'name' => $skill->name,
|
||||
];
|
||||
});
|
||||
|
||||
return Inertia::render('Employer/Jobs/Create', [
|
||||
'professions' => $professions,
|
||||
'availableSkills' => $skills,
|
||||
]);
|
||||
}
|
||||
|
||||
public function store(Request $request)
|
||||
@ -156,6 +181,8 @@ public function store(Request $request)
|
||||
|
||||
$request->validate([
|
||||
'title' => 'required|string|max:255',
|
||||
'main_profession' => 'required|string|max:255',
|
||||
'skills' => 'required',
|
||||
'workers_needed' => 'required|integer|min:1',
|
||||
'location' => 'required|string|max:255',
|
||||
'salary' => 'required|numeric|min:0',
|
||||
@ -165,9 +192,10 @@ public function store(Request $request)
|
||||
'requirements' => 'nullable|string',
|
||||
]);
|
||||
|
||||
JobPost::create([
|
||||
$job = JobPost::create([
|
||||
'employer_id' => $user->id,
|
||||
'title' => $request->title,
|
||||
'main_profession' => $request->main_profession,
|
||||
'workers_needed' => $request->workers_needed,
|
||||
'job_type' => $request->job_type,
|
||||
'location' => $request->location,
|
||||
@ -178,6 +206,27 @@ public function store(Request $request)
|
||||
'status' => 'active',
|
||||
]);
|
||||
|
||||
// Sync skills
|
||||
$skillsInput = $request->skills;
|
||||
$skillIds = [];
|
||||
if (is_array($skillsInput)) {
|
||||
foreach ($skillsInput as $skillNameOrId) {
|
||||
if (is_numeric($skillNameOrId)) {
|
||||
$skillIds[] = (int)$skillNameOrId;
|
||||
} else {
|
||||
$skill = \App\Models\Skill::firstOrCreate(['name' => trim($skillNameOrId)]);
|
||||
$skillIds[] = $skill->id;
|
||||
}
|
||||
}
|
||||
} else if (is_string($skillsInput)) {
|
||||
$skillsArray = array_filter(array_map('trim', explode(',', $skillsInput)));
|
||||
foreach ($skillsArray as $name) {
|
||||
$skill = \App\Models\Skill::firstOrCreate(['name' => $name]);
|
||||
$skillIds[] = $skill->id;
|
||||
}
|
||||
}
|
||||
$job->skills()->sync($skillIds);
|
||||
|
||||
return redirect()->route('employer.jobs')->with('success', 'Job posted successfully.');
|
||||
}
|
||||
|
||||
@ -242,7 +291,7 @@ public function edit($id)
|
||||
->with('error', 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.');
|
||||
}
|
||||
|
||||
$job = JobPost::where('employer_id', $user->id)->where('id', $id)->firstOrFail();
|
||||
$job = JobPost::where('employer_id', $user->id)->where('id', $id)->with('skills')->firstOrFail();
|
||||
|
||||
// Convert start_date format to Y-m-d for date input
|
||||
$jobData = $job->toArray();
|
||||
@ -250,8 +299,40 @@ public function edit($id)
|
||||
$jobData['start_date'] = $job->start_date->format('Y-m-d');
|
||||
}
|
||||
|
||||
// Add skills list
|
||||
$jobData['skills'] = $job->skills->map(function ($s) {
|
||||
return [
|
||||
'id' => $s->id,
|
||||
'name' => $s->name,
|
||||
];
|
||||
})->toArray();
|
||||
|
||||
$professions = \App\Models\Skill::orderBy('name')
|
||||
->pluck('name')
|
||||
->map(function ($name) {
|
||||
return ucwords($name);
|
||||
})
|
||||
->unique()
|
||||
->values()
|
||||
->toArray();
|
||||
|
||||
if (empty($professions)) {
|
||||
$professions = [
|
||||
'Housekeeper', 'Nanny', 'Maid', 'Electrician', 'Mason', 'Plumber', 'Cleaner', 'Driver', 'Caregiver', 'Cook'
|
||||
];
|
||||
}
|
||||
|
||||
$skills = \App\Models\Skill::all()->map(function ($skill) {
|
||||
return [
|
||||
'id' => $skill->id,
|
||||
'name' => $skill->name,
|
||||
];
|
||||
});
|
||||
|
||||
return Inertia::render('Employer/Jobs/Edit', [
|
||||
'job' => $jobData,
|
||||
'professions' => $professions,
|
||||
'availableSkills' => $skills,
|
||||
]);
|
||||
}
|
||||
|
||||
@ -269,8 +350,14 @@ public function update(Request $request, $id)
|
||||
|
||||
$job = JobPost::where('employer_id', $user->id)->where('id', $id)->firstOrFail();
|
||||
|
||||
if ($job->applications()->exists()) {
|
||||
return back()->withErrors(['general' => 'This job cannot be edited because workers have already applied for it.']);
|
||||
}
|
||||
|
||||
$request->validate([
|
||||
'title' => 'required|string|max:255',
|
||||
'main_profession' => 'required|string|max:255',
|
||||
'skills' => 'required',
|
||||
'workers_needed' => 'required|integer|min:1',
|
||||
'location' => 'required|string|max:255',
|
||||
'salary' => 'required|numeric|min:0',
|
||||
@ -283,6 +370,7 @@ public function update(Request $request, $id)
|
||||
|
||||
$job->update([
|
||||
'title' => $request->title,
|
||||
'main_profession' => $request->main_profession,
|
||||
'workers_needed' => $request->workers_needed,
|
||||
'job_type' => $request->job_type,
|
||||
'location' => $request->location,
|
||||
@ -293,6 +381,27 @@ public function update(Request $request, $id)
|
||||
'status' => strtolower($request->status),
|
||||
]);
|
||||
|
||||
// Sync skills
|
||||
$skillsInput = $request->skills;
|
||||
$skillIds = [];
|
||||
if (is_array($skillsInput)) {
|
||||
foreach ($skillsInput as $skillNameOrId) {
|
||||
if (is_numeric($skillNameOrId)) {
|
||||
$skillIds[] = (int)$skillNameOrId;
|
||||
} else {
|
||||
$skill = \App\Models\Skill::firstOrCreate(['name' => trim($skillNameOrId)]);
|
||||
$skillIds[] = $skill->id;
|
||||
}
|
||||
}
|
||||
} else if (is_string($skillsInput)) {
|
||||
$skillsArray = array_filter(array_map('trim', explode(',', $skillsInput)));
|
||||
foreach ($skillsArray as $name) {
|
||||
$skill = \App\Models\Skill::firstOrCreate(['name' => $name]);
|
||||
$skillIds[] = $skill->id;
|
||||
}
|
||||
}
|
||||
$job->skills()->sync($skillIds);
|
||||
|
||||
return redirect()->route('employer.jobs')->with('success', 'Job updated successfully.');
|
||||
}
|
||||
|
||||
|
||||
@ -37,6 +37,21 @@ private function resolveCurrentUser()
|
||||
return null;
|
||||
}
|
||||
|
||||
private function getWorkerStatus($employerId, $workerId, $defaultStatus)
|
||||
{
|
||||
$pendingOfferOrApp = JobOffer::where('employer_id', $employerId)
|
||||
->where('worker_id', $workerId)
|
||||
->where('status', 'pending')
|
||||
->exists() ||
|
||||
\App\Models\JobApplication::where('worker_id', $workerId)
|
||||
->whereHas('jobPost', function($q) use ($employerId) {
|
||||
$q->where('employer_id', $employerId);
|
||||
})->where('status', 'hire_requested')
|
||||
->exists();
|
||||
|
||||
return $pendingOfferOrApp ? 'pending_confirmation' : strtolower($defaultStatus ?? 'active');
|
||||
}
|
||||
|
||||
public static function processWorkerResponse($conv, $worker, $text)
|
||||
{
|
||||
$replyText = strtolower(trim($text));
|
||||
@ -105,13 +120,13 @@ public function index(Request $request)
|
||||
->latest('updated_at')
|
||||
->get();
|
||||
|
||||
$conversations = $dbConversations->map(function ($conv) {
|
||||
$conversations = $dbConversations->map(function ($conv) use ($user) {
|
||||
$lastMsg = $conv->messages->last();
|
||||
return [
|
||||
'id' => $conv->id,
|
||||
'worker_id' => $conv->worker_id,
|
||||
'worker_name' => $conv->worker->name ?? 'Candidate',
|
||||
'worker_status' => strtolower($conv->worker->status ?? 'active'),
|
||||
'worker_status' => $this->getWorkerStatus($user->id, $conv->worker_id, $conv->worker->status),
|
||||
'last_message' => $lastMsg->text ?? 'No messages yet.',
|
||||
'unread' => $lastMsg ? ($lastMsg->sender_type === 'worker' && is_null($lastMsg->read_at)) : false,
|
||||
'online' => true,
|
||||
@ -136,13 +151,13 @@ public function show($id)
|
||||
->latest('updated_at')
|
||||
->get();
|
||||
|
||||
$conversations = $dbConversations->map(function ($conv) {
|
||||
$conversations = $dbConversations->map(function ($conv) use ($user) {
|
||||
$lastMsg = $conv->messages->last();
|
||||
return [
|
||||
'id' => $conv->id,
|
||||
'worker_id' => $conv->worker_id,
|
||||
'worker_name' => $conv->worker->name ?? 'Candidate',
|
||||
'worker_status' => strtolower($conv->worker->status ?? 'active'),
|
||||
'worker_status' => $this->getWorkerStatus($user->id, $conv->worker_id, $conv->worker->status),
|
||||
'last_message' => $lastMsg->text ?? 'No messages yet.',
|
||||
'unread' => $lastMsg ? ($lastMsg->sender_type === 'worker' && is_null($lastMsg->read_at)) : false,
|
||||
'online' => true,
|
||||
@ -165,7 +180,7 @@ public function show($id)
|
||||
'id' => $activeConv->id,
|
||||
'worker_id' => $activeConv->worker_id,
|
||||
'worker_name' => $activeConv->worker->name ?? 'Candidate',
|
||||
'worker_status' => strtolower($activeConv->worker->status ?? 'active'),
|
||||
'worker_status' => $this->getWorkerStatus($user->id, $activeConv->worker_id, $activeConv->worker->status),
|
||||
'online' => true,
|
||||
'salary' => ($activeConv->worker->salary ?? 2000) . ' AED',
|
||||
'nationality' => $activeConv->worker->nationality ?? 'Unknown',
|
||||
|
||||
@ -365,6 +365,16 @@ public function show($id)
|
||||
|
||||
$visaStatus = $w->visa_status;
|
||||
|
||||
$pendingOfferOrApp = JobOffer::where('employer_id', $user ? $user->id : 0)
|
||||
->where('worker_id', $w->id)
|
||||
->where('status', 'pending')
|
||||
->exists() ||
|
||||
\App\Models\JobApplication::where('worker_id', $w->id)
|
||||
->whereHas('jobPost', function($q) use ($user) {
|
||||
$q->where('employer_id', $user ? $user->id : 0);
|
||||
})->where('status', 'hire_requested')
|
||||
->exists();
|
||||
|
||||
$worker = [
|
||||
'id' => $w->id,
|
||||
'name' => $w->name,
|
||||
@ -392,7 +402,7 @@ public function show($id)
|
||||
'reviews' => $reviews,
|
||||
'similar_workers' => $similarWorkers,
|
||||
'status' => $w->status,
|
||||
'availability_status' => $w->status,
|
||||
'availability_status' => $pendingOfferOrApp ? 'Pending Confirmation' : ($w->status === 'Hired' ? 'Hired' : $w->status),
|
||||
'document_expiry_status' => $w->document_expiry_status,
|
||||
'document_expiry_days' => $w->document_expiry_days,
|
||||
'visa_expiry_date' => $w->visa_expiry_date,
|
||||
@ -479,9 +489,8 @@ public function markHired(Request $request, $id)
|
||||
}
|
||||
|
||||
$worker = Worker::findOrFail($id);
|
||||
$worker->update([
|
||||
'status' => 'Hired',
|
||||
]);
|
||||
|
||||
// Do NOT update worker status to 'Hired' directly.
|
||||
|
||||
// Check if there is an existing job offer for this employer and worker
|
||||
$offer = JobOffer::where('employer_id', $employerId)
|
||||
@ -489,7 +498,7 @@ public function markHired(Request $request, $id)
|
||||
->first();
|
||||
|
||||
if ($offer) {
|
||||
$offer->update(['status' => 'accepted']);
|
||||
$offer->update(['status' => 'pending']);
|
||||
} else {
|
||||
// Also check if there's an existing job application
|
||||
$jobIds = \App\Models\JobPost::where('employer_id', $employerId)->pluck('id');
|
||||
@ -498,21 +507,41 @@ public function markHired(Request $request, $id)
|
||||
->first();
|
||||
|
||||
if ($application) {
|
||||
$application->update(['status' => 'hired']);
|
||||
$application->update(['status' => 'hire_requested']);
|
||||
|
||||
$history = $application->status_history ?: [];
|
||||
$history[] = [
|
||||
'status' => 'hire_requested',
|
||||
'timestamp' => now()->toIso8601String(),
|
||||
'notes' => 'Hire request initiated by employer.',
|
||||
];
|
||||
$application->update(['status_history' => $history]);
|
||||
} else {
|
||||
// Create a new direct job offer with status accepted
|
||||
JobOffer::create([
|
||||
// Create a new direct job offer with status pending
|
||||
$offer = JobOffer::create([
|
||||
'employer_id' => $employerId,
|
||||
'worker_id' => $worker->id,
|
||||
'work_date' => now()->format('Y-m-d'),
|
||||
'location' => 'Dubai',
|
||||
'salary' => $worker->salary,
|
||||
'notes' => 'Direct sponsoring matching finalized.',
|
||||
'status' => 'accepted',
|
||||
'notes' => 'Direct sponsoring matching initiated.',
|
||||
'status' => 'pending',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return back()->with('success', 'Worker successfully marked as Hired!');
|
||||
// Notify the worker
|
||||
$worker->notify(new \App\Notifications\GenericNotification(
|
||||
"Action Required: Confirm Hiring",
|
||||
"Employer " . ($user->name ?? "Employer") . " has sent you a hire request. Please confirm.",
|
||||
[
|
||||
'type' => 'hire_request',
|
||||
'offer_id' => $offer->id ?? '',
|
||||
'application_id' => $application->id ?? '',
|
||||
'status' => $offer ? 'pending' : 'hire_requested',
|
||||
]
|
||||
));
|
||||
|
||||
return back()->with('success', 'Hire request initiated successfully! Awaiting candidate confirmation.');
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,6 +13,7 @@ class JobPost extends Model
|
||||
protected $fillable = [
|
||||
'employer_id',
|
||||
'title',
|
||||
'main_profession',
|
||||
'workers_needed',
|
||||
'job_type',
|
||||
'location',
|
||||
@ -34,6 +35,10 @@ public function employer()
|
||||
return $this->belongsTo(User::class, 'employer_id');
|
||||
}
|
||||
|
||||
public function skills()
|
||||
{
|
||||
return $this->belongsToMany(Skill::class, 'job_post_skills', 'job_post_id', 'skill_id');
|
||||
}
|
||||
|
||||
public function applications()
|
||||
{
|
||||
|
||||
65
app/Notifications/GenericNotification.php
Normal file
65
app/Notifications/GenericNotification.php
Normal 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),
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -193,10 +193,11 @@ export default function Applicants({ job, applicants: initialApplicants, isShort
|
||||
<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 ${
|
||||
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' :
|
||||
'bg-amber-100 text-amber-700'
|
||||
'bg-blue-50 text-[#185FA5]'
|
||||
}`} onClick={() => openStatusModal(applicant)}>
|
||||
{applicant.status}
|
||||
{applicant.status?.toLowerCase() === 'hire_requested' ? 'Pending Confirmation' : applicant.status}
|
||||
</span>
|
||||
<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">
|
||||
@ -261,6 +262,11 @@ export default function Applicants({ job, applicants: initialApplicants, isShort
|
||||
|
||||
{/* Modal Body */}
|
||||
<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 */}
|
||||
<div className="space-y-2">
|
||||
<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="Selected">Selected</option>
|
||||
<option value="Rejected">Rejected</option>
|
||||
<option value="Hired">Hired</option>
|
||||
<option value="Hired">Hired (Request Confirmation)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
@ -7,29 +7,31 @@ import {
|
||||
Briefcase,
|
||||
MapPin,
|
||||
DollarSign,
|
||||
Calendar,
|
||||
LayoutGrid,
|
||||
Users,
|
||||
FileText,
|
||||
Sparkles,
|
||||
ShieldCheck
|
||||
ShieldCheck,
|
||||
X
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export default function Create({ categories: initialCategories }) {
|
||||
const categories = initialCategories || [
|
||||
'Electrician', 'Mason', 'Plumber', 'Cleaner', 'Site Supervisor', 'Driver', 'General Helper'
|
||||
export default function Create({ professions, availableSkills }) {
|
||||
const categoryList = professions || [
|
||||
'Housekeeper', 'Nanny', 'Maid', 'Electrician', 'Mason', 'Plumber', 'Cleaner', 'Driver', 'Caregiver', 'Cook'
|
||||
];
|
||||
const skillList = availableSkills || [];
|
||||
|
||||
const { data, setData, post, processing, errors } = useForm({
|
||||
title: '',
|
||||
main_profession: '',
|
||||
skills: [],
|
||||
workers_needed: 1,
|
||||
location: '',
|
||||
salary: '',
|
||||
job_type: 'Full Time',
|
||||
start_date: '',
|
||||
start_date: new Date().toISOString().split('T')[0],
|
||||
description: '',
|
||||
requirements: ''
|
||||
});
|
||||
|
||||
const [clientErrors, setClientErrors] = useState({});
|
||||
@ -42,6 +44,12 @@ export default function Create({ categories: initialCategories }) {
|
||||
if (!data.title.trim()) {
|
||||
newErrors.title = 'Job title is required.';
|
||||
}
|
||||
if (!data.main_profession) {
|
||||
newErrors.main_profession = 'Main profession is required.';
|
||||
}
|
||||
if (!data.skills || data.skills.length === 0) {
|
||||
newErrors.skills = 'At least one skill is required.';
|
||||
}
|
||||
if (!data.workers_needed || data.workers_needed < 1) {
|
||||
newErrors.workers_needed = 'Workers needed must be at least 1.';
|
||||
}
|
||||
@ -133,7 +141,29 @@ export default function Create({ categories: initialCategories }) {
|
||||
)}
|
||||
</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>
|
||||
<div className="relative">
|
||||
<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 className="space-y-2">
|
||||
<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">
|
||||
<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" />
|
||||
@ -201,7 +201,7 @@ export default function Create({ categories: initialCategories }) {
|
||||
)}
|
||||
</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>
|
||||
<div className="relative">
|
||||
<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 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'
|
||||
<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-2 gap-2">
|
||||
{['Full Time', 'Part Time'].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'
|
||||
}`}
|
||||
value={data.start_date}
|
||||
onChange={e => setData('start_date', e.target.value)}
|
||||
/>
|
||||
>
|
||||
{type.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</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 */}
|
||||
{/* 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" />
|
||||
@ -248,6 +250,56 @@ export default function Create({ categories: initialCategories }) {
|
||||
</h3>
|
||||
|
||||
<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">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Job Description</label>
|
||||
<textarea
|
||||
@ -268,19 +320,6 @@ export default function Create({ categories: initialCategories }) {
|
||||
</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>
|
||||
|
||||
|
||||
@ -7,26 +7,32 @@ import {
|
||||
Briefcase,
|
||||
MapPin,
|
||||
DollarSign,
|
||||
Calendar,
|
||||
LayoutGrid,
|
||||
Users,
|
||||
FileText,
|
||||
Sparkles,
|
||||
ShieldCheck,
|
||||
Save
|
||||
Save,
|
||||
X
|
||||
} from 'lucide-react';
|
||||
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({
|
||||
title: job.title || '',
|
||||
main_profession: job.main_profession || '',
|
||||
skills: job.skills ? job.skills.map(s => s.name) : [],
|
||||
workers_needed: job.workers_needed || 1,
|
||||
location: job.location || '',
|
||||
salary: job.salary ? Math.round(job.salary) : '',
|
||||
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 || '',
|
||||
requirements: job.requirements || '',
|
||||
status: job.status || 'active'
|
||||
});
|
||||
|
||||
@ -40,6 +46,12 @@ export default function Edit({ job }) {
|
||||
if (!data.title.trim()) {
|
||||
newErrors.title = 'Job title is required.';
|
||||
}
|
||||
if (!data.main_profession) {
|
||||
newErrors.main_profession = 'Main profession is required.';
|
||||
}
|
||||
if (!data.skills || data.skills.length === 0) {
|
||||
newErrors.skills = 'At least one skill is required.';
|
||||
}
|
||||
if (!data.workers_needed || data.workers_needed < 1) {
|
||||
newErrors.workers_needed = 'Workers needed must be at least 1.';
|
||||
}
|
||||
@ -130,7 +142,29 @@ export default function Edit({ job }) {
|
||||
)}
|
||||
</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>
|
||||
<div className="relative">
|
||||
<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 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>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{['Full Time', 'Part Time', 'Contract'].map(type => (
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{['Full Time', 'Part Time'].map(type => (
|
||||
<button
|
||||
key={type}
|
||||
type="button"
|
||||
@ -169,7 +241,7 @@ export default function Edit({ job }) {
|
||||
</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>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{[
|
||||
@ -195,73 +267,7 @@ export default function Edit({ job }) {
|
||||
</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>
|
||||
<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 */}
|
||||
{/* 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" />
|
||||
@ -269,6 +275,56 @@ export default function Edit({ job }) {
|
||||
</h3>
|
||||
|
||||
<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">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest ml-1">Job Description</label>
|
||||
<textarea
|
||||
@ -289,19 +345,6 @@ export default function Edit({ job }) {
|
||||
</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>
|
||||
|
||||
|
||||
@ -27,7 +27,8 @@ import {
|
||||
UploadCloud,
|
||||
FileText,
|
||||
Mic,
|
||||
Square
|
||||
Square,
|
||||
Clock
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
@ -192,7 +193,9 @@ export default function Show({ conversation, initialMessages, conversations = []
|
||||
router.post(`/employer/workers/${conversation.worker_id}/mark-hired`, {}, {
|
||||
preserveScroll: true,
|
||||
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();
|
||||
}
|
||||
});
|
||||
@ -312,7 +315,12 @@ export default function Show({ conversation, initialMessages, conversations = []
|
||||
</div>
|
||||
|
||||
<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
|
||||
type="button"
|
||||
onClick={() => setShowHireConfirmModal(true)}
|
||||
|
||||
@ -25,7 +25,8 @@ import {
|
||||
AlertTriangle,
|
||||
User,
|
||||
Phone,
|
||||
MapPin
|
||||
MapPin,
|
||||
Clock
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
@ -161,10 +162,10 @@ export default function Show({ worker }) {
|
||||
router.post(`/employer/workers/${worker.id}/mark-hired`, {}, {
|
||||
preserveScroll: true,
|
||||
onSuccess: () => {
|
||||
setAvailabilityStatus('Hired');
|
||||
setAvailabilityStatus('Pending Confirmation');
|
||||
setShowHiredModal(true);
|
||||
toast.success(t('marked_hired', 'Worker successfully marked as Hired!'), {
|
||||
description: t('marked_hired_desc', 'Sponsorship direct match finalized. Please leave an anonymous trust review!')
|
||||
toast.success(t('hire_request_sent', 'Hiring request sent successfully!'), {
|
||||
description: t('hire_request_sent_desc', 'Awaiting worker confirmation of the hiring offer.')
|
||||
});
|
||||
}
|
||||
});
|
||||
@ -290,7 +291,12 @@ export default function Show({ worker }) {
|
||||
</Link>
|
||||
|
||||
{/* 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
|
||||
type="button"
|
||||
onClick={() => setShowHireConfirmModal(true)}
|
||||
@ -885,6 +891,28 @@ 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="bg-white rounded-3xl w-full max-w-md p-6 relative animate-zoom-in space-y-6 border border-slate-200 shadow-2xl">
|
||||
|
||||
{availabilityStatus === 'Pending Confirmation' ? (
|
||||
<>
|
||||
<div className="text-center space-y-3">
|
||||
<div className="w-16 h-16 bg-amber-50 text-amber-600 rounded-full flex items-center justify-center mx-auto shadow-inner">
|
||||
<Clock className="w-8 h-8" />
|
||||
</div>
|
||||
<h4 className="font-extrabold text-lg text-slate-900">{t('hire_request_sent_title', 'Hiring Request Sent')}</h4>
|
||||
<p className="text-xs text-slate-500 leading-relaxed">
|
||||
{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" />
|
||||
@ -920,6 +948,8 @@ export default function Show({ worker }) {
|
||||
{t('back_to_worker_directory', 'Back to Worker Directory')}
|
||||
</Link>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -115,6 +115,8 @@
|
||||
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::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)
|
||||
Route::get('/workers/reviews', [\App\Http\Controllers\Api\WorkerReviewController::class, 'getReviews']);
|
||||
|
||||
@ -107,11 +107,19 @@ public function test_employer_can_view_jobs_list()
|
||||
*/
|
||||
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)
|
||||
->withSession(['user' => $this->employer])
|
||||
->get('/employer/jobs/create');
|
||||
|
||||
$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])
|
||||
->post('/employer/jobs/create', [
|
||||
'title' => 'Villa Cleaner',
|
||||
'main_profession' => 'Cleaner',
|
||||
'skills' => 'Housekeeping',
|
||||
'workers_needed' => 2,
|
||||
'job_type' => 'Part Time',
|
||||
'location' => 'Al Barsha',
|
||||
@ -138,6 +148,7 @@ public function test_employer_can_post_job()
|
||||
$this->assertDatabaseHas('job_posts', [
|
||||
'employer_id' => $this->employer->id,
|
||||
'title' => 'Villa Cleaner',
|
||||
'main_profession' => 'Cleaner',
|
||||
'workers_needed' => 2,
|
||||
'job_type' => 'Part Time',
|
||||
'location' => 'Al Barsha',
|
||||
@ -158,7 +169,7 @@ public function test_job_posting_requires_fields()
|
||||
'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->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
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@ -499,6 +499,12 @@ public function test_worker_hired_via_job_application_appears_in_employers_list(
|
||||
|
||||
$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
|
||||
$this->assertDatabaseHas('job_offers', [
|
||||
'employer_id' => $employer->id,
|
||||
|
||||
@ -62,7 +62,11 @@ protected function setUp(): void
|
||||
'religion' => 'Christian',
|
||||
'bio' => 'Helper info',
|
||||
'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()
|
||||
@ -72,6 +76,8 @@ public function test_employer_job_crud_web()
|
||||
->withSession(['user' => $this->employer])
|
||||
->post('/employer/jobs/create', [
|
||||
'title' => 'Test Mason Job',
|
||||
'main_profession' => 'Plumber',
|
||||
'skills' => 'Piping',
|
||||
'workers_needed' => 3,
|
||||
'location' => 'Dubai',
|
||||
'salary' => 2000,
|
||||
@ -103,6 +109,8 @@ public function test_employer_job_crud_web()
|
||||
->withSession(['user' => $this->employer])
|
||||
->post("/employer/jobs/{$job->id}/edit", [
|
||||
'title' => 'Updated Mason Job',
|
||||
'main_profession' => 'Plumber',
|
||||
'skills' => 'Piping',
|
||||
'workers_needed' => 2,
|
||||
'location' => 'Abu Dhabi',
|
||||
'salary' => 2500,
|
||||
@ -137,6 +145,7 @@ public function test_worker_job_apis()
|
||||
$job = JobPost::create([
|
||||
'employer_id' => $this->employer->id,
|
||||
'title' => 'Available Plumber Job',
|
||||
'main_profession' => 'Plumber',
|
||||
'location' => 'Dubai Marina',
|
||||
'salary' => 3000,
|
||||
'workers_needed' => 1,
|
||||
@ -145,6 +154,8 @@ public function test_worker_job_apis()
|
||||
'description' => 'Plumber description',
|
||||
'status' => 'active',
|
||||
]);
|
||||
$skill = \App\Models\Skill::firstOrCreate(['name' => 'Piping']);
|
||||
$job->skills()->sync([$skill->id]);
|
||||
|
||||
// 1. List available 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
|
||||
$response = $this->postJson('/api/employers/jobs', [
|
||||
'title' => 'API Clean Job',
|
||||
'main_profession' => 'Plumber',
|
||||
'skills' => 'Piping',
|
||||
'workers_needed' => 3,
|
||||
'location' => 'Dubai JLT',
|
||||
'salary' => 2200,
|
||||
@ -263,6 +276,8 @@ public function test_employer_job_apis_and_access_restrictions()
|
||||
// 4. Update job via API
|
||||
$response = $this->postJson("/api/employers/jobs/{$jobId}/update", [
|
||||
'title' => 'Updated API Clean Job',
|
||||
'main_profession' => 'Plumber',
|
||||
'skills' => 'Piping',
|
||||
'workers_needed' => 4,
|
||||
'location' => 'Dubai Marina',
|
||||
'salary' => 2500,
|
||||
@ -523,6 +538,7 @@ public function test_hired_worker_visibility_and_profile_access()
|
||||
$job = JobPost::create([
|
||||
'employer_id' => $this->employer->id,
|
||||
'title' => 'Special Hired Job',
|
||||
'main_profession' => 'Plumber',
|
||||
'location' => 'Dubai Jumeirah',
|
||||
'salary' => 4500,
|
||||
'workers_needed' => 1,
|
||||
@ -531,13 +547,15 @@ public function test_hired_worker_visibility_and_profile_access()
|
||||
'description' => 'A special job details description',
|
||||
'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::first();
|
||||
$app = JobApplication::latest()->first();
|
||||
|
||||
// 3. Employer hires the worker by setting status to 'hired' (lowercase, as in the database)
|
||||
$response = $this->postJson("/api/employers/applications/{$app->id}/status", [
|
||||
@ -548,6 +566,18 @@ public function test_hired_worker_visibility_and_profile_access()
|
||||
]);
|
||||
$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
|
||||
$this->assertDatabaseHas('job_applications', [
|
||||
'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.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'
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user