diff --git a/app/Http/Controllers/Api/EmployerAuthController.php b/app/Http/Controllers/Api/EmployerAuthController.php index 8e2e1e6..031576b 100644 --- a/app/Http/Controllers/Api/EmployerAuthController.php +++ b/app/Http/Controllers/Api/EmployerAuthController.php @@ -252,6 +252,10 @@ public function register(Request $request) $emiratesIdIssuePlace = $emiratesIdInput['issue_place'] ?? $emiratesIdInput['issue place'] ?? $emiratesIdInput['issuing_place'] ?? null; $emiratesIdOccupation = $emiratesIdInput['occupation'] ?? $emiratesIdInput['occuption'] ?? null; $emiratesIdCardNumber = $emiratesIdInput['card_number'] ?? null; + + $emiratesIdExpiry = $this->normaliseDate($emiratesIdExpiry); + $emiratesIdDob = $this->normaliseDate($emiratesIdDob); + $emiratesIdIssueDate = $this->normaliseDate($emiratesIdIssueDate); } // Create inactive User @@ -606,38 +610,55 @@ public function password(Request $request) public function plans() { + $dbPlans = \App\Models\Plan::where('status', 'Active')->get(); + if ($dbPlans->isEmpty()) { + $plans = [ + [ + 'id' => 'basic', + 'name' => 'Basic Search', + 'price' => 99.00, + 'currency' => 'AED', + 'period' => 'month', + 'features' => ['Browse 500+ verified workers', 'Shortlist up to 10 candidates', 'Standard OCR verification'], + 'popular' => false, + ], + [ + 'id' => 'premium', + 'name' => 'Premium Employer Pass', + 'price' => 199.00, + 'currency' => 'AED', + 'period' => 'month', + 'features' => ['Unlimited shortlisting', 'Direct candidate messaging', 'Priority interview scheduling', 'Dedicated support'], + 'popular' => true, + ], + [ + 'id' => 'enterprise', + 'name' => 'VIP Concierge', + 'price' => 499.00, + 'currency' => 'AED', + 'period' => 'month', + 'features' => ['All Premium features', 'Assigned recruitment manager', 'Background medical verification guarantee', 'Free replacement within 30 days'], + 'popular' => false, + ] + ]; + } else { + $plans = $dbPlans->map(function ($plan) { + return [ + 'id' => $plan->id === 'vip' ? 'enterprise' : $plan->id, + 'name' => $plan->name, + 'price' => (float)$plan->price, + 'currency' => 'AED', + 'period' => 'month', + 'features' => $plan->features, + 'popular' => $plan->id === 'premium', + ]; + })->toArray(); + } + return response()->json([ 'success' => true, 'data' => [ - 'plans' => [ - [ - 'id' => 'basic', - 'name' => 'Basic Search', - 'price' => 99.00, - 'currency' => 'AED', - 'period' => 'month', - 'features' => ['Browse 500+ verified workers', 'Shortlist up to 10 candidates', 'Standard OCR verification'], - 'popular' => false, - ], - [ - 'id' => 'premium', - 'name' => 'Premium Employer Pass', - 'price' => 199.00, - 'currency' => 'AED', - 'period' => 'month', - 'features' => ['Unlimited shortlisting', 'Direct candidate messaging', 'Priority interview scheduling', 'Dedicated support'], - 'popular' => true, - ], - [ - 'id' => 'enterprise', - 'name' => 'VIP Concierge', - 'price' => 499.00, - 'currency' => 'AED', - 'period' => 'month', - 'features' => ['All Premium features', 'Assigned recruitment manager', 'Background medical verification guarantee', 'Free replacement within 30 days'], - 'popular' => false, - ] - ] + 'plans' => $plans ] ]); } @@ -779,4 +800,36 @@ public function resetPassword(Request $request) ], 500); } } + + /** + * Normalise date format to Y-m-d. + * E.g. "06/06/2025" -> "2025-06-06" + */ + private function normaliseDate(?string $raw): ?string + { + if (empty($raw)) { + return null; + } + $raw = trim($raw); + if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $raw)) { + return $raw; + } + try { + if (preg_match('/^(\d{1,2})[\/\- ]([A-Za-z0-9]+)[\/\- ](\d{4})$/', $raw, $m)) { + $day = str_pad($m[1], 2, '0', STR_PAD_LEFT); + $month = $m[2]; + $year = $m[3]; + if (is_numeric($month)) { + $month = str_pad($month, 2, '0', STR_PAD_LEFT); + return "{$year}-{$month}-{$day}"; + } + $ts = strtotime("{$day} {$month} {$year}"); + return $ts ? date('Y-m-d', $ts) : $raw; + } + $dt = new \DateTime($raw); + return $dt->format('Y-m-d'); + } catch (\Exception $e) { + return $raw; + } + } } diff --git a/app/Http/Controllers/Api/EmployerMessageController.php b/app/Http/Controllers/Api/EmployerMessageController.php index e2ab989..25c4bd8 100644 --- a/app/Http/Controllers/Api/EmployerMessageController.php +++ b/app/Http/Controllers/Api/EmployerMessageController.php @@ -334,10 +334,28 @@ public function startConversation(Request $request) try { // Find or create conversation - $conv = Conversation::firstOrCreate([ - 'employer_id' => $employer->id, - 'worker_id' => $request->worker_id, - ]); + $conv = Conversation::where('employer_id', $employer->id) + ->where('worker_id', $request->worker_id) + ->first(); + + if (!$conv) { + if (!User::canContactWorker($employer->id, $request->worker_id)) { + $activeSub = \App\Models\Subscription::where('user_id', $employer->id)->where('status', 'active')->first(); + $planId = $activeSub ? $activeSub->plan_id : 'basic'; + $plan = \App\Models\Plan::find($planId); + $maxContacts = $plan ? $plan->max_contact_people : 10; + + return response()->json([ + 'success' => false, + 'message' => 'You have reached your contacted workers limit of ' . $maxContacts . ' under your active plan. Please upgrade or renew your subscription to contact more workers.' + ], 403); + } + + $conv = Conversation::create([ + 'employer_id' => $employer->id, + 'worker_id' => $request->worker_id, + ]); + } return response()->json([ 'success' => true, diff --git a/app/Http/Controllers/Api/EmployerProfileController.php b/app/Http/Controllers/Api/EmployerProfileController.php index 2bb4cc5..3b605e7 100644 --- a/app/Http/Controllers/Api/EmployerProfileController.php +++ b/app/Http/Controllers/Api/EmployerProfileController.php @@ -38,10 +38,15 @@ public function getProfile(Request $request) 'name' => $employer->name, 'email' => $employer->email, 'phone' => $profile->phone, + 'address' => $profile->address, 'country' => $profile->country, 'language' => $profile->language ?? 'English', 'notifications' => (bool)($profile->notifications ?? true), 'verification_status' => $profile->verification_status ?? 'approved', + 'id_number' => $profile->emirates_id_number, + 'card_number' => $profile->emirates_id_card_number, + 'full_name' => $profile->emirates_id_name, + 'gender' => $profile->emirates_id_gender, 'emirates_id' => [ 'emirates_id_number' => $profile->emirates_id_number, 'name' => $profile->emirates_id_name, @@ -51,7 +56,7 @@ public function getProfile(Request $request) 'employer' => $profile->emirates_id_employer, 'issue_place' => $profile->emirates_id_issue_place, 'occupation' => $profile->emirates_id_occupation, - 'nationality' => $profile->emirates_id_nationality, + 'nationality' => $profile->nationality, 'gender' => $profile->emirates_id_gender, 'card_number' => $profile->emirates_id_card_number, 'country' => 'United Arab Emirates', @@ -190,7 +195,7 @@ public function updateProfile(Request $request) $sponsorData['emirates_id_name'] = $request->full_name; } if ($request->has('date_of_birth')) { - $sponsorData['emirates_id_dob'] = $request->date_of_birth; + $sponsorData['emirates_id_dob'] = $this->normaliseDate($request->date_of_birth); } if ($request->has('nationality')) { $sponsorData['nationality'] = $request->nationality; @@ -199,10 +204,10 @@ public function updateProfile(Request $request) $sponsorData['emirates_id_gender'] = $request->gender; } if ($request->has('issue_date')) { - $sponsorData['emirates_id_issue_date'] = $request->issue_date; + $sponsorData['emirates_id_issue_date'] = $this->normaliseDate($request->issue_date); } if ($request->has('expiry_date')) { - $sponsorData['emirates_id_expiry'] = $request->expiry_date; + $sponsorData['emirates_id_expiry'] = $this->normaliseDate($request->expiry_date); } if ($request->has('occupation')) { $sponsorData['emirates_id_occupation'] = $request->occupation; @@ -236,7 +241,7 @@ public function updateProfile(Request $request) $profile->emirates_id_name = $request->full_name; } if ($request->has('date_of_birth')) { - $profile->emirates_id_dob = $request->date_of_birth; + $profile->emirates_id_dob = $this->normaliseDate($request->date_of_birth); } if ($request->has('nationality')) { $profile->nationality = $request->nationality; @@ -245,10 +250,10 @@ public function updateProfile(Request $request) $profile->emirates_id_gender = $request->gender; } if ($request->has('issue_date')) { - $profile->emirates_id_issue_date = $request->issue_date; + $profile->emirates_id_issue_date = $this->normaliseDate($request->issue_date); } if ($request->has('expiry_date')) { - $profile->emirates_id_expiry = $request->expiry_date; + $profile->emirates_id_expiry = $this->normaliseDate($request->expiry_date); } if ($request->has('occupation')) { $profile->emirates_id_occupation = $request->occupation; @@ -266,10 +271,15 @@ public function updateProfile(Request $request) 'name' => $employer->name, 'email' => $employer->email, 'phone' => $profile->phone, + 'address' => $profile->address, 'country' => $profile->country, 'language' => $profile->language, 'notifications' => (bool)$profile->notifications, 'verification_status' => $profile->verification_status ?? 'approved', + 'id_number' => $profile->emirates_id_number, + 'card_number' => $profile->emirates_id_card_number, + 'full_name' => $profile->emirates_id_name, + 'gender' => $profile->emirates_id_gender, 'emirates_id' => [ 'emirates_id_number' => $profile->emirates_id_number, 'name' => $profile->emirates_id_name, @@ -279,7 +289,7 @@ public function updateProfile(Request $request) 'employer' => $profile->emirates_id_employer, 'issue_place' => $profile->emirates_id_issue_place, 'occupation' => $profile->emirates_id_occupation, - 'nationality' => $profile->emirates_id_nationality, + 'nationality' => $profile->nationality, 'gender' => $profile->emirates_id_gender, 'card_number' => $profile->emirates_id_card_number, 'country' => 'United Arab Emirates', @@ -483,4 +493,35 @@ public function changePassword(Request $request) } } + /** + * Normalise date format to Y-m-d. + * E.g. "06/06/2025" -> "2025-06-06" + */ + private function normaliseDate(?string $raw): ?string + { + if (empty($raw)) { + return null; + } + $raw = trim($raw); + if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $raw)) { + return $raw; + } + try { + if (preg_match('/^(\d{1,2})[\/\- ]([A-Za-z0-9]+)[\/\- ](\d{4})$/', $raw, $m)) { + $day = str_pad($m[1], 2, '0', STR_PAD_LEFT); + $month = $m[2]; + $year = $m[3]; + if (is_numeric($month)) { + $month = str_pad($month, 2, '0', STR_PAD_LEFT); + return "{$year}-{$month}-{$day}"; + } + $ts = strtotime("{$day} {$month} {$year}"); + return $ts ? date('Y-m-d', $ts) : $raw; + } + $dt = new \DateTime($raw); + return $dt->format('Y-m-d'); + } catch (\Exception $e) { + return $raw; + } + } } diff --git a/app/Http/Controllers/Api/EmployerWorkerController.php b/app/Http/Controllers/Api/EmployerWorkerController.php index b37d03a..d5b7faf 100644 --- a/app/Http/Controllers/Api/EmployerWorkerController.php +++ b/app/Http/Controllers/Api/EmployerWorkerController.php @@ -737,6 +737,39 @@ public function hireCandidate(Request $request, $id = null) $updatedApplication = null; $updatedOffer = null; + // Resolve the worker ID to perform limit check + $resolvedWorkerId = null; + if (is_string($targetId) && str_starts_with($targetId, 'offer_')) { + $offerId = substr($targetId, 6); + $offer = JobOffer::where('id', $offerId)->where('employer_id', $employerId)->first(); + if ($offer) { + $resolvedWorkerId = $offer->worker_id; + } + } else { + $application = JobApplication::where('id', $targetId) + ->whereHas('jobPost', function($q) use ($employerId) { + $q->where('employer_id', $employerId); + })->first(); + + if ($application) { + $resolvedWorkerId = $application->worker_id; + } else { + $resolvedWorkerId = $targetId; + } + } + + if ($resolvedWorkerId && !\App\Models\User::canContactWorker($employerId, $resolvedWorkerId)) { + $activeSub = \App\Models\Subscription::where('user_id', $employerId)->where('status', 'active')->first(); + $planId = $activeSub ? $activeSub->plan_id : 'basic'; + $plan = \App\Models\Plan::find($planId); + $maxContacts = $plan ? $plan->max_contact_people : 10; + + return response()->json([ + 'success' => false, + 'message' => 'You have reached your contacted workers limit of ' . $maxContacts . ' under your active plan. Please upgrade or renew your subscription to contact more workers.' + ], 403); + } + // Direct Offer check if (is_string($targetId) && str_starts_with($targetId, 'offer_')) { $offerId = substr($targetId, 6); diff --git a/app/Http/Controllers/Api/SponsorAuthController.php b/app/Http/Controllers/Api/SponsorAuthController.php index 5a7a949..1f42583 100644 --- a/app/Http/Controllers/Api/SponsorAuthController.php +++ b/app/Http/Controllers/Api/SponsorAuthController.php @@ -103,6 +103,10 @@ public function register(Request $request) $emiratesIdIssuePlace = $emiratesIdInput['issue_place'] ?? $emiratesIdInput['issue place'] ?? null; $emiratesIdOccupation = $emiratesIdInput['occupation'] ?? $emiratesIdInput['occuption'] ?? null; $emiratesIdCardNumber = $emiratesIdInput['card_number'] ?? null; + + $emiratesIdExpiry = $this->normaliseDate($emiratesIdExpiry); + $emiratesIdDob = $this->normaliseDate($emiratesIdDob); + $emiratesIdIssueDate = $this->normaliseDate($emiratesIdIssueDate); } $emiratesIdPath = null; @@ -495,5 +499,37 @@ public function resetPassword(Request $request) 'message' => 'Password reset successfully. You can now log in with your new password.', ]); } + + /** + * Normalise date format to Y-m-d. + * E.g. "06/06/2025" -> "2025-06-06" + */ + private function normaliseDate(?string $raw): ?string + { + if (empty($raw)) { + return null; + } + $raw = trim($raw); + if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $raw)) { + return $raw; + } + try { + if (preg_match('/^(\d{1,2})[\/\- ]([A-Za-z0-9]+)[\/\- ](\d{4})$/', $raw, $m)) { + $day = str_pad($m[1], 2, '0', STR_PAD_LEFT); + $month = $m[2]; + $year = $m[3]; + if (is_numeric($month)) { + $month = str_pad($month, 2, '0', STR_PAD_LEFT); + return "{$year}-{$month}-{$day}"; + } + $ts = strtotime("{$day} {$month} {$year}"); + return $ts ? date('Y-m-d', $ts) : $raw; + } + $dt = new \DateTime($raw); + return $dt->format('Y-m-d'); + } catch (\Exception $e) { + return $raw; + } + } } diff --git a/app/Http/Controllers/Api/SponsorController.php b/app/Http/Controllers/Api/SponsorController.php index 98462a3..641b1e0 100644 --- a/app/Http/Controllers/Api/SponsorController.php +++ b/app/Http/Controllers/Api/SponsorController.php @@ -634,6 +634,11 @@ public function updateProfile(Request $request) $eidCardNumber = $eidCardNumber ?? $request->input('card_number'); $eidGender = $eidGender ?? $request->input('gender'); + // Normalize dates + $eidDob = $this->normaliseDate($eidDob); + $eidExpiry = $this->normaliseDate($eidExpiry); + $eidIssueDate = $this->normaliseDate($eidIssueDate); + $nameToUse = $eidName ?? $request->name ?? $request->full_name; // Validate Emirates ID immutability — once set, it cannot be changed @@ -738,6 +743,13 @@ public function updateProfile(Request $request) } if (!empty($newLicenseData)) { + if (isset($newLicenseData['expiry_date'])) { + $newLicenseData['expiry_date'] = $this->normaliseDate($newLicenseData['expiry_date']); + $sponsorData['license_expiry'] = $newLicenseData['expiry_date']; + } + if (isset($newLicenseData['issue_date'])) { + $newLicenseData['issue_date'] = $this->normaliseDate($newLicenseData['issue_date']); + } $sponsorData['license_data'] = array_merge($currentLicenseData, $newLicenseData); } @@ -761,4 +773,36 @@ public function updateProfile(Request $request) ], 500); } } + + /** + * Normalise date format to Y-m-d. + * E.g. "06/06/2025" -> "2025-06-06" + */ + private function normaliseDate(?string $raw): ?string + { + if (empty($raw)) { + return null; + } + $raw = trim($raw); + if (preg_match('/^\d{4}-\d{2}-\d{2}$/', $raw)) { + return $raw; + } + try { + if (preg_match('/^(\d{1,2})[\/\- ]([A-Za-z0-9]+)[\/\- ](\d{4})$/', $raw, $m)) { + $day = str_pad($m[1], 2, '0', STR_PAD_LEFT); + $month = $m[2]; + $year = $m[3]; + if (is_numeric($month)) { + $month = str_pad($month, 2, '0', STR_PAD_LEFT); + return "{$year}-{$month}-{$day}"; + } + $ts = strtotime("{$day} {$month} {$year}"); + return $ts ? date('Y-m-d', $ts) : $raw; + } + $dt = new \DateTime($raw); + return $dt->format('Y-m-d'); + } catch (\Exception $e) { + return $raw; + } + } } diff --git a/app/Http/Controllers/Api/WorkerJobController.php b/app/Http/Controllers/Api/WorkerJobController.php new file mode 100644 index 0000000..256a2ca --- /dev/null +++ b/app/Http/Controllers/Api/WorkerJobController.php @@ -0,0 +1,808 @@ +with(['employer.employerProfile']) + ->latest() + ->get() + ->map(function ($job) { + return [ + 'id' => $job->id, + 'title' => $job->title, + 'location' => $job->location, + 'salary' => (int) $job->salary, + 'workers_needed' => $job->workers_needed, + 'job_type' => $job->job_type, + 'start_date' => $job->start_date ? $job->start_date->format('Y-m-d') : null, + 'description' => $job->description, + 'requirements' => $job->requirements, + 'company_name' => $job->employer->employerProfile->company_name ?? 'Private Sponsor', + 'posted_at' => $job->created_at->toIso8601String(), + ]; + }); + + return response()->json([ + 'success' => true, + 'data' => [ + 'jobs' => $jobs + ] + ]); + } + + /** + * API to retrieve job details. + * GET /api/workers/jobs/{id} + */ + public function jobDetails($id) + { + $job = JobPost::where('status', 'active') + ->with(['employer.employerProfile']) + ->find($id); + + if (!$job) { + return response()->json([ + 'success' => false, + 'message' => 'Job not found.' + ], 404); + } + + return response()->json([ + 'success' => true, + 'data' => [ + 'job' => [ + 'id' => $job->id, + 'title' => $job->title, + 'location' => $job->location, + 'salary' => (int) $job->salary, + 'workers_needed' => $job->workers_needed, + 'job_type' => $job->job_type, + 'start_date' => $job->start_date ? $job->start_date->format('Y-m-d') : null, + 'description' => $job->description, + 'requirements' => $job->requirements, + 'company_name' => $job->employer->employerProfile->company_name ?? 'Private Sponsor', + 'posted_at' => $job->created_at->toIso8601String(), + ] + ] + ]); + } + + /** + * API for workers to apply for a job. + * POST /api/workers/jobs/{id}/apply + */ + public function applyJob(Request $request, $id) + { + /** @var Worker $worker */ + $worker = $request->attributes->get('worker'); + + $job = JobPost::where('status', 'active')->find($id); + + if (!$job) { + return response()->json([ + 'success' => false, + 'message' => 'Job not found.' + ], 404); + } + + // Prevent duplicate applications + $exists = JobApplication::where('worker_id', $worker->id) + ->where('job_id', $job->id) + ->exists(); + + if ($exists) { + return response()->json([ + 'success' => false, + 'message' => 'You have already applied for this job.' + ], 409); + } + + $application = JobApplication::create([ + 'worker_id' => $worker->id, + 'job_id' => $job->id, + 'status' => 'applied' + ]); + + return response()->json([ + 'success' => true, + 'message' => 'Successfully applied for the job.', + 'data' => [ + 'application' => $application + ] + ], 201); + } + + /** + * API for workers to view their applied jobs. + * GET /api/workers/applied-jobs + */ + public function appliedJobs(Request $request) + { + /** @var Worker $worker */ + $worker = $request->attributes->get('worker'); + + $applications = JobApplication::where('worker_id', $worker->id) + ->with(['jobPost.employer.employerProfile']) + ->latest() + ->get() + ->map(function ($app) { + $job = $app->jobPost; + return [ + 'application_id' => $app->id, + 'status' => $app->status, + 'applied_at' => $app->created_at->toIso8601String(), + 'job' => $job ? [ + 'id' => $job->id, + 'title' => $job->title, + 'location' => $job->location, + 'salary' => (int) $job->salary, + 'job_type' => $job->job_type, + 'company_name' => $job->employer->employerProfile->company_name ?? 'Private Sponsor', + ] : null + ]; + }); + + return response()->json([ + 'success' => true, + 'data' => [ + 'applications' => $applications + ] + ]); + } + + private function checkJobAccess($employerId) + { + $sub = \Illuminate\Support\Facades\DB::table('subscriptions') + ->where('user_id', $employerId) + ->where('status', 'active') + ->latest('id') + ->first(); + + $planId = $sub ? $sub->plan_id : 'basic'; + return $planId !== 'basic'; + } + + /** + * API for employers to list their jobs. + * GET /api/employers/jobs + */ + public function employerListJobs(Request $request) + { + /** @var User $employer */ + $employer = $request->attributes->get('employer'); + + if (!$this->checkJobAccess($employer->id)) { + return response()->json([ + 'success' => false, + 'message' => 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.' + ], 403); + } + + $jobs = JobPost::where('employer_id', $employer->id) + ->with(['applications']) + ->latest() + ->get() + ->map(function ($job) { + return [ + 'id' => $job->id, + 'title' => $job->title, + 'location' => $job->location, + 'salary' => (int) $job->salary, + 'workers_needed' => $job->workers_needed, + 'job_type' => $job->job_type, + 'start_date' => $job->start_date ? $job->start_date->format('Y-m-d') : null, + 'description' => $job->description, + 'requirements' => $job->requirements, + 'applied_count' => $job->applications->count(), + 'posted_at' => $job->created_at->toIso8601String(), + 'status' => $job->status, + ]; + }); + + return response()->json([ + 'success' => true, + 'data' => [ + 'jobs' => $jobs + ] + ]); + } + + /** + * API for employers to create a job. + * POST /api/employers/jobs + */ + public function employerCreateJob(Request $request) + { + /** @var User $employer */ + $employer = $request->attributes->get('employer'); + + if (!$this->checkJobAccess($employer->id)) { + return response()->json([ + 'success' => false, + 'message' => 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.' + ], 403); + } + + $validator = Validator::make($request->all(), [ + 'title' => 'required|string|max:255', + 'workers_needed' => 'required|integer|min:1', + 'location' => 'required|string|max:255', + 'salary' => 'required|numeric|min:0', + 'job_type' => 'required|string|in:Full Time,Part Time,Contract', + 'start_date' => 'required|date', + 'description' => 'required|string|max:1000', + 'requirements' => 'nullable|string', + ]); + + if ($validator->fails()) { + return response()->json([ + 'success' => false, + 'message' => 'Validation error.', + 'errors' => $validator->errors() + ], 422); + } + + $job = JobPost::create([ + 'employer_id' => $employer->id, + 'title' => $request->title, + 'workers_needed' => $request->workers_needed, + 'job_type' => $request->job_type, + 'location' => $request->location, + 'salary' => $request->salary, + 'start_date' => $request->start_date, + 'description' => $request->description, + 'requirements' => $request->requirements, + 'status' => 'active', + ]); + + return response()->json([ + 'success' => true, + 'message' => 'Job posted successfully.', + 'data' => [ + 'job' => [ + 'id' => $job->id, + 'title' => $job->title, + 'location' => $job->location, + 'salary' => (int) $job->salary, + 'workers_needed' => $job->workers_needed, + 'job_type' => $job->job_type, + 'start_date' => $job->start_date ? $job->start_date->format('Y-m-d') : null, + 'description' => $job->description, + 'requirements' => $job->requirements, + 'posted_at' => $job->created_at->toIso8601String(), + 'status' => $job->status, + ] + ] + ], 201); + } + + /** + * API for employers to retrieve details of a specific job. + * GET /api/employers/jobs/{id} + */ + public function employerJobDetail(Request $request, $id) + { + /** @var User $employer */ + $employer = $request->attributes->get('employer'); + + if (!$this->checkJobAccess($employer->id)) { + return response()->json([ + 'success' => false, + 'message' => 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.' + ], 403); + } + + $job = JobPost::where('employer_id', $employer->id) + ->with(['applications']) + ->find($id); + + if (!$job) { + return response()->json([ + 'success' => false, + 'message' => 'Job not found or unauthorized.' + ], 404); + } + + return response()->json([ + 'success' => true, + 'data' => [ + 'job' => [ + 'id' => $job->id, + 'title' => $job->title, + 'location' => $job->location, + 'salary' => (int) $job->salary, + 'workers_needed' => $job->workers_needed, + 'job_type' => $job->job_type, + 'start_date' => $job->start_date ? $job->start_date->format('Y-m-d') : null, + 'description' => $job->description, + 'requirements' => $job->requirements, + 'applied_count' => $job->applications->count(), + 'posted_at' => $job->created_at->toIso8601String(), + 'status' => $job->status, + ] + ] + ]); + } + + /** + * API for employers to update a specific job. + * POST /api/employers/jobs/{id}/update + */ + public function employerUpdateJob(Request $request, $id) + { + /** @var User $employer */ + $employer = $request->attributes->get('employer'); + + if (!$this->checkJobAccess($employer->id)) { + return response()->json([ + 'success' => false, + 'message' => 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.' + ], 403); + } + + $job = JobPost::where('employer_id', $employer->id)->find($id); + + if (!$job) { + return response()->json([ + 'success' => false, + 'message' => 'Job not found or unauthorized.' + ], 404); + } + + $validator = Validator::make($request->all(), [ + 'title' => 'required|string|max:255', + 'workers_needed' => 'required|integer|min:1', + 'location' => 'required|string|max:255', + 'salary' => 'required|numeric|min:0', + 'job_type' => 'required|string|in:Full Time,Part Time,Contract', + 'start_date' => 'required|date', + 'description' => 'required|string|max:1000', + 'requirements' => 'nullable|string', + 'status' => 'required|string|in:active,closed,draft', + ]); + + if ($validator->fails()) { + return response()->json([ + 'success' => false, + 'message' => 'Validation error.', + 'errors' => $validator->errors() + ], 422); + } + + $job->update([ + 'title' => $request->title, + 'workers_needed' => $request->workers_needed, + 'job_type' => $request->job_type, + 'location' => $request->location, + 'salary' => $request->salary, + 'start_date' => $request->start_date, + 'description' => $request->description, + 'requirements' => $request->requirements, + 'status' => strtolower($request->status), + ]); + + return response()->json([ + 'success' => true, + 'message' => 'Job updated successfully.', + 'data' => [ + 'job' => [ + 'id' => $job->id, + 'title' => $job->title, + 'location' => $job->location, + 'salary' => (int) $job->salary, + 'workers_needed' => $job->workers_needed, + 'job_type' => $job->job_type, + 'start_date' => $job->start_date ? $job->start_date->format('Y-m-d') : null, + 'description' => $job->description, + 'requirements' => $job->requirements, + 'posted_at' => $job->created_at->toIso8601String(), + 'status' => $job->status, + ] + ] + ]); + } + + /** + * API for employers to delete a job. + * DELETE /api/employers/jobs/{id} + */ + public function employerDeleteJob(Request $request, $id) + { + /** @var User $employer */ + $employer = $request->attributes->get('employer'); + + if (!$this->checkJobAccess($employer->id)) { + return response()->json([ + 'success' => false, + 'message' => 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.' + ], 403); + } + + $job = JobPost::where('employer_id', $employer->id)->find($id); + + if (!$job) { + return response()->json([ + 'success' => false, + 'message' => 'Job not found or unauthorized.' + ], 404); + } + + $job->delete(); + + return response()->json([ + 'success' => true, + 'message' => 'Job deleted successfully.' + ]); + } + + /** + * API for employers to view applicants for each job. + * GET /api/employers/jobs/{id}/applicants + */ + public function employerJobApplicants(Request $request, $id) + { + /** @var User $employer */ + $employer = $request->attributes->get('employer'); + + if (!$this->checkJobAccess($employer->id)) { + return response()->json([ + 'success' => false, + 'message' => 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.' + ], 403); + } + + $job = JobPost::where('employer_id', $employer->id)->find($id); + + if (!$job) { + return response()->json([ + 'success' => false, + 'message' => 'Job not found or unauthorized.' + ], 404); + } + + $query = JobApplication::where('job_id', $job->id) + ->with(['worker.skills']); + + // Search filter: name, nationality + if ($request->filled('search')) { + $search = $request->search; + $query->whereHas('worker', function($q) use ($search) { + $q->where('name', 'like', "%{$search}%") + ->orWhere('nationality', 'like', "%{$search}%"); + }); + } + + // Status filter + if ($request->filled('status')) { + $query->where('status', strtolower($request->status)); + } + + $sortBy = $request->input('sort_by', 'created_at'); + $sortOrder = $request->input('sort_order', 'desc'); + + if ($sortBy === 'created_at' || $sortBy === 'applied_at') { + $query->orderBy('created_at', $sortOrder === 'asc' ? 'asc' : 'desc'); + } + + $applicants = $query->get()->map(function ($app) use ($employer) { + $w = $app->worker; + if (!$w) return null; + + // Check if worker is saved in Saved Workers (Shortlist) + $isSaved = \App\Models\Shortlist::where('employer_id', $employer->id) + ->where('worker_id', $w->id) + ->exists(); + + return [ + 'application_id' => $app->id, + 'status' => $app->status, + 'notes' => $app->notes, + 'status_history' => $app->status_history ?: [], + 'applied_at' => $app->created_at->toIso8601String(), + 'is_saved' => $isSaved, + 'worker' => [ + 'id' => $w->id, + 'name' => $w->name, + 'nationality' => $w->nationality, + 'salary' => (int) $w->salary, + 'preferred_location' => $w->preferred_location, + 'preferred_job_type' => $w->preferred_job_type, + 'visa_status' => $w->visa_status, + 'experience' => $w->experience, + 'skills' => $w->skills->pluck('name')->toArray(), + ] + ]; + })->filter()->values(); + + return response()->json([ + 'success' => true, + 'data' => [ + 'job' => [ + 'id' => $job->id, + 'title' => $job->title, + ], + 'applicants' => $applicants + ] + ]); + } + + /** + * API for employers to update application status. + * POST /api/employers/applications/{id}/status + */ + public function employerUpdateApplicationStatus(Request $request, $id) + { + /** @var User $employer */ + $employer = $request->attributes->get('employer'); + + if (!$this->checkJobAccess($employer->id)) { + return response()->json([ + 'success' => false, + 'message' => 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.' + ], 403); + } + + $validator = Validator::make($request->all(), [ + 'status' => 'required|string|in:applied,shortlisted,contacted,interview scheduled,interview_scheduled,selected,rejected,hired', + 'notes' => 'nullable|string', + ]); + + if ($validator->fails()) { + return response()->json([ + 'success' => false, + 'message' => 'Validation error.', + 'errors' => $validator->errors() + ], 422); + } + + $application = JobApplication::where('id', $id) + ->whereHas('jobPost', function($q) use ($employer) { + $q->where('employer_id', $employer->id); + })->first(); + + if (!$application) { + return response()->json([ + 'success' => false, + 'message' => 'Job application not found or unauthorized.' + ], 404); + } + + $dbStatus = strtolower($request->status); + + // Contact Limit check: if moving to 'shortlisted', 'contacted', 'interview_scheduled', 'selected', 'hired' for the first time + $contactStatuses = ['shortlisted', 'contacted', 'interview_scheduled', 'selected', 'hired']; + 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(); + $planId = $activeSub ? $activeSub->plan_id : 'basic'; + $plan = \App\Models\Plan::find($planId); + $maxContacts = $plan ? $plan->max_contact_people : 10; + + return response()->json([ + 'success' => false, + 'message' => 'You have reached your contacted workers limit of ' . $maxContacts . ' under your active plan. Please upgrade or renew your subscription to contact more workers.' + ], 403); + } + } + + $notes = $request->input('notes'); + + // Update application status & history + $history = $application->status_history ?: []; + $history[] = [ + 'status' => $dbStatus, + 'timestamp' => now()->toIso8601String(), + 'notes' => $notes, + ]; + + $updateData = [ + 'status' => $dbStatus, + 'status_history' => $history, + ]; + + if ($notes !== null) { + $updateData['notes'] = $notes; + } + + $application->update($updateData); + + // If shortlisted, automatically add to Saved Workers (Shortlist table) if not already saved + if ($dbStatus === 'shortlisted') { + $exists = \App\Models\Shortlist::where('employer_id', $employer->id) + ->where('worker_id', $application->worker_id) + ->exists(); + if (!$exists) { + \App\Models\Shortlist::create([ + 'employer_id' => $employer->id, + 'worker_id' => $application->worker_id, + ]); + } + } + + // If hired, also update worker status + if ($dbStatus === 'hired') { + if ($application->worker) { + $application->worker->update(['status' => 'Hired']); + } + } elseif ($dbStatus === 'rejected') { + if ($application->worker) { + $application->worker->update(['status' => 'active']); + } + } + + // Trigger notifications + $this->triggerStatusNotification($application, $dbStatus); + + return response()->json([ + 'success' => true, + 'message' => 'Application status updated successfully.', + 'data' => [ + 'application' => $application->fresh() + ] + ]); + } + + /** + * API for workers to view details of a specific job application. + * GET /api/workers/applied-jobs/{id} + */ + public function appliedJobDetail(Request $request, $id) + { + /** @var Worker $worker */ + $worker = $request->attributes->get('worker'); + + $application = JobApplication::where('worker_id', $worker->id) + ->with(['jobPost.employer.employerProfile']) + ->find($id); + + if (!$application) { + return response()->json([ + 'success' => false, + 'message' => 'Application not found.' + ], 404); + } + + $job = $application->jobPost; + + return response()->json([ + 'success' => true, + 'data' => [ + 'application' => [ + 'id' => $application->id, + 'status' => $application->status, + 'status_history' => $application->status_history ?: [], + 'applied_at' => $application->created_at->toIso8601String(), + 'job' => $job ? [ + 'id' => $job->id, + 'title' => $job->title, + 'location' => $job->location, + 'salary' => (int) $job->salary, + 'job_type' => $job->job_type, + 'description' => $job->description, + 'requirements' => $job->requirements, + 'company_name' => $job->employer->employerProfile->company_name ?? 'Private Sponsor', + 'posted_at' => $job->created_at->toIso8601String(), + ] : null + ] + ] + ]); + } + + /** + * API for workers to withdraw their job application. + * POST /api/workers/applied-jobs/{id}/withdraw + */ + public function withdrawApplication(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) !== 'applied') { + return response()->json([ + 'success' => false, + 'message' => 'You can only withdraw applications that are still in "Applied" status.' + ], 400); + } + + $application->delete(); + + return response()->json([ + 'success' => true, + 'message' => 'Application successfully withdrawn.' + ]); + } + + /** + * Dispatch FCM Push Notification. + */ + private function triggerStatusNotification($application, $status) + { + $worker = $application->worker; + $job = $application->jobPost; + $employer = $job ? $job->employer : null; + + // When a new application is submitted: notify employer + if ($status === 'applied') { + if ($employer && $employer->fcm_token) { + \App\Services\FCMService::sendPushNotification( + $employer->fcm_token, + "New Job Application", + "A candidate has applied for your job: " . ($job->title ?? 'Job Post'), + [ + 'type' => 'new_job_application', + 'job_id' => $job->id, + 'application_id' => $application->id, + ] + ); + } + return; + } + + // When status is updated: notify worker + if ($worker && $worker->fcm_token) { + $title = "Job Application Update"; + $body = ""; + switch (strtolower($status)) { + case 'shortlisted': + $body = "You have been shortlisted for the job: " . ($job->title ?? 'Job Post'); + break; + case 'contacted': + $body = "An employer wants to contact you regarding the job: " . ($job->title ?? 'Job Post'); + break; + case 'interview scheduled': + case 'interview_scheduled': + $body = "An interview has been scheduled for the job: " . ($job->title ?? 'Job Post'); + break; + case 'selected': + $body = "Congratulations! You have been selected for the job: " . ($job->title ?? 'Job Post'); + break; + case 'rejected': + $body = "Your application for the job: " . ($job->title ?? 'Job Post') . " has been reviewed and rejected."; + break; + case 'hired': + $body = "Congratulations! You have been hired for the job: " . ($job->title ?? 'Job Post'); + 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, + $title, + $body, + [ + 'type' => 'job_application_status_update', + 'job_id' => $job->id ?? '', + 'application_id' => $application->id, + 'status' => $status, + ] + ); + } + } +} diff --git a/app/Http/Controllers/Employer/CandidateController.php b/app/Http/Controllers/Employer/CandidateController.php index 0176621..0993973 100644 --- a/app/Http/Controllers/Employer/CandidateController.php +++ b/app/Http/Controllers/Employer/CandidateController.php @@ -275,10 +275,27 @@ public function index(Request $request) ]); } + private function checkJobAccess($user) + { + if (!$user) { + return false; + } + + $sub = \Illuminate\Support\Facades\DB::table('subscriptions') + ->where('user_id', $user->id) + ->where('status', 'active') + ->latest('id') + ->first(); + + $planId = $sub ? $sub->plan_id : 'basic'; + return $planId !== 'basic'; + } + public function updateStatus(Request $request, $id) { $request->validate([ - 'status' => 'required|string|in:Reviewing,Offer Sent,Hired,Rejected', + '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', + 'notes' => 'nullable|string', ]); // If this is a direct hiring offer response @@ -287,13 +304,13 @@ public function updateStatus(Request $request, $id) $offer = JobOffer::findOrFail($offerId); $dbStatus = 'pending'; - if ($request->status === 'Hired') { + if (in_array(strtolower($request->status), ['hired', 'accepted'])) { $dbStatus = 'accepted'; $offer->worker->update(['status' => 'Hired']); - } elseif ($request->status === 'Rejected') { + } elseif (in_array(strtolower($request->status), ['rejected'])) { $dbStatus = 'rejected'; $offer->worker->update(['status' => 'active']); - } elseif ($request->status === 'Offer Sent') { + } elseif (in_array(strtolower($request->status), ['offer sent', 'offer_sent', 'pending'])) { $dbStatus = 'pending'; } @@ -305,27 +322,161 @@ public function updateStatus(Request $request, $id) // Standard job application status update $app = JobApplication::findOrFail($id); + $user = $this->resolveCurrentUser(); + if (!$user) { + return redirect()->route('employer.login'); + } + + if (!$this->checkJobAccess($user)) { + return redirect()->route('employer.subscription') + ->with('error', 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.'); + } + $dbStatus = 'applied'; - if ($request->status === 'Hired') { + $statusLower = strtolower($request->status); + if ($statusLower === 'hired') { $dbStatus = 'hired'; - if ($app->worker) { - $app->worker->update(['status' => 'Hired']); - } - } elseif ($request->status === 'Rejected') { + } elseif ($statusLower === 'rejected') { $dbStatus = 'rejected'; - if ($app->worker) { - $app->worker->update(['status' => 'active']); - } - } elseif ($request->status === 'Offer Sent') { + } elseif ($statusLower === 'shortlisted' || $statusLower === 'offer sent' || $statusLower === 'offer_sent') { $dbStatus = 'shortlisted'; - } elseif ($request->status === 'Reviewing') { + } elseif ($statusLower === 'contacted') { + $dbStatus = 'contacted'; + } elseif ($statusLower === 'interview scheduled' || $statusLower === 'interview_scheduled') { + $dbStatus = 'interview_scheduled'; + } elseif ($statusLower === 'selected') { + $dbStatus = 'selected'; + } elseif ($statusLower === 'reviewing' || $statusLower === 'applied') { $dbStatus = 'applied'; } - $app->update([ + // Contact Limit check: if moving to 'shortlisted', 'contacted', 'interview_scheduled', 'selected', 'hired' + $contactStatuses = ['shortlisted', 'contacted', 'interview_scheduled', 'selected', 'hired']; + 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(); + $planId = $activeSub ? $activeSub->plan_id : 'basic'; + $plan = \App\Models\Plan::find($planId); + $maxContacts = $plan ? $plan->max_contact_people : 10; + + // Web: contact limit block redirect + return redirect()->route('employer.subscription') + ->with('error', 'You have reached your contacted workers limit of ' . $maxContacts . ' under your active plan. Please upgrade or renew your subscription to contact more workers.'); + } + } + + // Update application status & history + $history = $app->status_history ?: []; + $history[] = [ 'status' => $dbStatus, - ]); + 'timestamp' => now()->toIso8601String(), + 'notes' => $request->input('notes'), + ]; + + $updateData = [ + 'status' => $dbStatus, + 'status_history' => $history, + ]; + + if ($request->filled('notes')) { + $updateData['notes'] = $request->notes; + } + + $app->update($updateData); + + // If shortlisted, automatically add to Saved Workers (Shortlist table) if not already saved + if ($dbStatus === 'shortlisted') { + $exists = \App\Models\Shortlist::where('employer_id', $user->id) + ->where('worker_id', $app->worker_id) + ->exists(); + if (!$exists) { + \App\Models\Shortlist::create([ + 'employer_id' => $user->id, + 'worker_id' => $app->worker_id, + ]); + } + } + + // If hired, also update worker status + if ($dbStatus === 'hired') { + if ($app->worker) { + $app->worker->update(['status' => 'Hired']); + } + } elseif ($dbStatus === 'rejected') { + if ($app->worker) { + $app->worker->update(['status' => 'active']); + } + } + + // Trigger notifications + $this->triggerStatusNotification($app, $dbStatus); return back()->with('success', 'Candidate application status updated successfully to ' . $request->status); } + + private function triggerStatusNotification($application, $status) + { + $worker = $application->worker; + $job = $application->jobPost; + $employer = $job ? $job->employer : null; + + // When a new application is submitted: notify employer + if ($status === 'applied') { + if ($employer && $employer->fcm_token) { + \App\Services\FCMService::sendPushNotification( + $employer->fcm_token, + "New Job Application", + "A candidate has applied for your job: " . ($job->title ?? 'Job Post'), + [ + 'type' => 'new_job_application', + 'job_id' => $job->id, + 'application_id' => $application->id, + ] + ); + } + return; + } + + // When status is updated: notify worker + if ($worker && $worker->fcm_token) { + $title = "Job Application Update"; + $body = ""; + switch (strtolower($status)) { + case 'shortlisted': + $body = "You have been shortlisted for the job: " . ($job->title ?? 'Job Post'); + break; + case 'contacted': + $body = "An employer wants to contact you regarding the job: " . ($job->title ?? 'Job Post'); + break; + case 'interview scheduled': + case 'interview_scheduled': + $body = "An interview has been scheduled for the job: " . ($job->title ?? 'Job Post'); + break; + case 'selected': + $body = "Congratulations! You have been selected for the job: " . ($job->title ?? 'Job Post'); + break; + case 'rejected': + $body = "Your application for the job: " . ($job->title ?? 'Job Post') . " has been reviewed and rejected."; + break; + case 'hired': + $body = "Congratulations! You have been hired for the job: " . ($job->title ?? 'Job Post'); + 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, + $title, + $body, + [ + 'type' => 'job_application_status_update', + 'job_id' => $job->id ?? '', + 'application_id' => $application->id, + 'status' => $status, + ] + ); + } + } } diff --git a/app/Http/Controllers/Employer/DashboardController.php b/app/Http/Controllers/Employer/DashboardController.php index 8bdbd42..dda629c 100644 --- a/app/Http/Controllers/Employer/DashboardController.php +++ b/app/Http/Controllers/Employer/DashboardController.php @@ -59,26 +59,18 @@ public function index(Request $request) $q->where('employer_id', $user->id); })->where('status', 'hired')->count(); + $totalHiredAll = \App\Models\JobOffer::where('status', 'accepted')->count() + + \App\Models\JobApplication::where('status', 'hired')->count(); + $totalActiveWorkers = \App\Models\Worker::where('status', 'active')->count(); + $stats = [ 'shortlisted_count' => $shortlistedCount, 'messages_sent' => $messagesSent, 'days_remaining' => (int)$daysRemaining, 'contacted_workers_count' => $contactedWorkersCount, 'hired_count' => $hiredCount, - 'analytics' => [ - 'profile_views' => 48, - 'response_rate' => '94%', - 'average_match_score' => '98%', - 'weekly_activity' => [ - ['day' => 'Mon', 'views' => 5], - ['day' => 'Tue', 'views' => 12], - ['day' => 'Wed', 'views' => 8], - ['day' => 'Thu', 'views' => 15], - ['day' => 'Fri', 'views' => 4], - ['day' => 'Sat', 'views' => 2], - ['day' => 'Sun', 'views' => 2], - ] - ], + 'total_hired_all' => $totalHiredAll, + 'total_active_workers' => $totalActiveWorkers, 'recent_failed_payment' => false ]; diff --git a/app/Http/Controllers/Employer/EmployerAuthController.php b/app/Http/Controllers/Employer/EmployerAuthController.php index bb368dd..8e812f5 100644 --- a/app/Http/Controllers/Employer/EmployerAuthController.php +++ b/app/Http/Controllers/Employer/EmployerAuthController.php @@ -42,10 +42,6 @@ public function login(Request $request) ]); } - if ($user->subscription_status === 'expired') { - return back()->with('status', 'subscription_expired'); - } - // Perform standard Laravel authentication (remember=true keeps auth cookie alive) auth()->login($user, true); @@ -65,6 +61,14 @@ public function login(Request $request) return redirect('/mobile/employer/home'); } + // Automatically check and activate pending subscription if any + \App\Models\User::checkAndActivatePendingSubscriptions($user->id); + $user->refresh(); + + if ($user->subscription_status !== 'active') { + return redirect()->route('employer.subscription'); + } + return redirect()->intended('/employer/dashboard'); } @@ -75,7 +79,9 @@ public function login(Request $request) public function showRegister() { - return Inertia::render('Employer/Auth/Register'); + return Inertia::render('Employer/Auth/Register', [ + 'prefillData' => session('pending_employer_registration'), + ]); } public function register(Request $request) @@ -273,6 +279,11 @@ public function showUploadEmiratesId() public function uploadEmiratesId(Request $request) { if (!session()->has('pending_employer_registration') || !session('employer_email_verified')) { + if ($request->wantsJson() || $request->ajax()) { + return response()->json([ + 'error' => 'Registration session expired. Please start over.' + ], 422); + } return redirect()->route('employer.register') ->with('error', 'Registration session expired. Please start over.'); } @@ -288,83 +299,42 @@ public function uploadEmiratesId(Request $request) 'emirates_id_back.mimes' => 'The Emirates ID back must be an image or a PDF.', ]); - $extractedIdNumber = null; - $extractedExpiry = null; - $extractedName = null; - $extractedDob = null; - $extractedNationality = null; - $extractedIssueDate = null; - $extractedEmployer = null; - $extractedIssuePlace = null; - $extractedOccupation = null; - $extractedCardNumber = null; - $extractedGender = null; - - if ($request->hasFile('emirates_id_front')) { - $frontFile = $request->file('emirates_id_front'); - $frontOcr = \App\Services\OcrDocumentService::extractEmiratesIdFrontData($frontFile); - $extractedIdNumber = $frontOcr['emirates_id_number']; - $extractedName = $frontOcr['name'] ?? null; - $extractedDob = $frontOcr['date_of_birth'] ?? null; - $extractedNationality = $frontOcr['nationality'] ?? null; - $extractedCardNumber = $frontOcr['card_number'] ?? null; - $extractedGender = $frontOcr['gender'] ?? null; - $extractedOccupation = $frontOcr['occupation'] ?? null; - $extractedEmployer = $frontOcr['employer'] ?? null; - $extractedIssuePlace = $frontOcr['issue_place'] ?? null; - } - - if ($request->hasFile('emirates_id_back')) { - $backFile = $request->file('emirates_id_back'); - $backOcr = \App\Services\OcrDocumentService::extractEmiratesIdBackData($backFile); - $extractedExpiry = $backOcr['expiry_date']; - $extractedIssueDate = $backOcr['issue_date'] ?? null; - } - if ($request->hasFile('emirates_id_file')) { - $file = $request->file('emirates_id_file'); - $frontOcr = \App\Services\OcrDocumentService::extractEmiratesIdFrontData($file); - $backOcr = \App\Services\OcrDocumentService::extractEmiratesIdBackData($file); - $extractedIdNumber = $frontOcr['emirates_id_number']; - $extractedExpiry = $backOcr['expiry_date']; - $extractedName = $frontOcr['name'] ?? null; - $extractedDob = $frontOcr['date_of_birth'] ?? null; - $extractedNationality = $frontOcr['nationality'] ?? null; - $extractedCardNumber = $frontOcr['card_number'] ?? null; - $extractedGender = $frontOcr['gender'] ?? null; - $extractedOccupation = $frontOcr['occupation'] ?? null; - $extractedEmployer = $frontOcr['employer'] ?? null; - $extractedIssuePlace = $frontOcr['issue_place'] ?? null; - $extractedIssueDate = $backOcr['issue_date'] ?? null; + $frontFile = $request->file('emirates_id_file'); + $backFile = $request->file('emirates_id_file'); + } else { + $frontFile = $request->file('emirates_id_front'); + $backFile = $request->file('emirates_id_back'); } - if (($request->hasFile('emirates_id_front') || $request->hasFile('emirates_id_file')) && empty($extractedName)) { + $extracted = \App\Services\OcrDocumentService::extractEmiratesIdCombinedData($frontFile, $backFile); + + if ($request->wantsJson() || $request->ajax()) { return response()->json([ - 'errors' => [ - 'emirates_id_front' => ['Failed to extract name from the Emirates ID. Please upload a clear image.'] - ] - ], 422); + 'success' => true, + 'data' => $extracted + ]); } $pending = session('pending_employer_registration'); $pending['emirates_id_file'] = null; - $pending['emirates_id_number'] = $extractedIdNumber; - $pending['emirates_id_expiry'] = $extractedExpiry; - $pending['emirates_id_name'] = $extractedName; - $pending['emirates_id_dob'] = $extractedDob; - $pending['emirates_id_nationality'] = $extractedNationality; - $pending['emirates_id_card_number'] = $extractedCardNumber; - $pending['emirates_id_gender'] = $extractedGender; - $pending['emirates_id_occupation'] = $extractedOccupation; - $pending['emirates_id_employer'] = $extractedEmployer; - $pending['emirates_id_issue_place'] = $extractedIssuePlace; - $pending['emirates_id_issue_date'] = $extractedIssueDate; + $pending['emirates_id_number'] = $extracted['emirates_id_number']; + $pending['emirates_id_expiry'] = $extracted['expiry_date']; + $pending['emirates_id_name'] = $extracted['name']; + $pending['emirates_id_dob'] = $extracted['date_of_birth']; + $pending['emirates_id_nationality'] = $extracted['nationality']; + $pending['emirates_id_card_number'] = $extracted['card_number']; + $pending['emirates_id_gender'] = $extracted['gender']; + $pending['emirates_id_occupation'] = $extracted['occupation']; + $pending['emirates_id_employer'] = $extracted['employer']; + $pending['emirates_id_issue_place'] = $extracted['issue_place']; + $pending['emirates_id_issue_date'] = $extracted['issue_date']; - if (!empty($extractedName)) { + if (!empty($extracted['name'])) { if (isset($pending['company_name']) && (empty($pending['company_name']) || $pending['company_name'] === ($pending['name'] ?? '') . ' Household')) { - $pending['company_name'] = $extractedName . ' Household'; + $pending['company_name'] = $extracted['name'] . ' Household'; } - $pending['name'] = $extractedName; + $pending['name'] = $extracted['name']; } session(['pending_employer_registration' => $pending]); @@ -374,6 +344,58 @@ public function uploadEmiratesId(Request $request) ->with('success', 'Emirates ID uploaded and verified successfully.'); } + public function confirmEmiratesId(Request $request) + { + if (!session()->has('pending_employer_registration') || !session('employer_email_verified')) { + return response()->json([ + 'error' => 'Registration session expired. Please start over.' + ], 422); + } + + $request->validate([ + 'emirates_id_number' => 'required|string', + 'expiry_date' => 'required|date_format:Y-m-d', + 'name' => 'required|string', + 'date_of_birth' => 'required|date_format:Y-m-d', + 'nationality' => 'nullable|string', + 'card_number' => 'nullable|string', + 'gender' => 'nullable|string', + 'occupation' => 'nullable|string', + 'employer' => 'nullable|string', + 'issue_place' => 'nullable|string', + 'issue_date' => 'nullable|date_format:Y-m-d', + ]); + + $pending = session('pending_employer_registration'); + $pending['emirates_id_file'] = null; + $pending['emirates_id_number'] = $request->emirates_id_number; + $pending['emirates_id_expiry'] = $request->expiry_date; + $pending['emirates_id_name'] = $request->name; + $pending['emirates_id_dob'] = $request->date_of_birth; + $pending['emirates_id_nationality'] = $request->nationality; + $pending['emirates_id_card_number'] = $request->card_number; + $pending['emirates_id_gender'] = $request->gender; + $pending['emirates_id_occupation'] = $request->occupation; + $pending['emirates_id_employer'] = $request->employer; + $pending['emirates_id_issue_place'] = $request->issue_place; + $pending['emirates_id_issue_date'] = $request->issue_date; + + if (!empty($request->name)) { + if (isset($pending['company_name']) && (empty($pending['company_name']) || $pending['company_name'] === ($pending['name'] ?? '') . ' Household')) { + $pending['company_name'] = $request->name . ' Household'; + } + $pending['name'] = $request->name; + } + + session(['pending_employer_registration' => $pending]); + session(['employer_emirates_id_uploaded' => true]); + + return response()->json([ + 'success' => true, + 'message' => 'Emirates ID verified and confirmed successfully.' + ]); + } + public function showRegisterPayment() { if (!session()->has('pending_employer_registration') || !session('employer_email_verified') || !session('employer_emirates_id_uploaded')) { @@ -381,9 +403,9 @@ public function showRegisterPayment() ->with('error', 'Please complete Emirates ID upload first.'); } - return Inertia::render('Employer/Auth/RegisterPayment', [ - 'email' => session('pending_employer_registration.email'), - 'plans' => [ + $dbPlans = \App\Models\Plan::where('status', 'Active')->get(); + if ($dbPlans->isEmpty()) { + $plans = [ [ 'id' => 'basic', 'name' => 'Basic Search Pass', @@ -408,7 +430,28 @@ public function showRegisterPayment() 'features' => ['All Premium features', 'Assigned recruitment manager', 'Background medical verification guarantee', 'Free replacement within 30 days'], 'popular' => false, ], - ] + ]; + } else { + $plans = $dbPlans->map(function ($plan) { + $id = $plan->id === 'vip' ? 'enterprise' : $plan->id; + $name = $plan->name; + if (!str_ends_with(strtolower($name), 'pass')) { + $name .= ' Pass'; + } + return [ + 'id' => $id, + 'name' => $name, + 'price' => (int)$plan->price . ' AED', + 'period' => 'month', + 'features' => $plan->features, + 'popular' => $plan->id === 'premium', + ]; + })->toArray(); + } + + return Inertia::render('Employer/Auth/RegisterPayment', [ + 'email' => session('pending_employer_registration.email'), + 'plans' => $plans ]); } diff --git a/app/Http/Controllers/Employer/JobController.php b/app/Http/Controllers/Employer/JobController.php index b028fab..ca4d4e9 100644 --- a/app/Http/Controllers/Employer/JobController.php +++ b/app/Http/Controllers/Employer/JobController.php @@ -35,6 +35,22 @@ private function resolveCurrentUser() return null; } + private function checkJobAccess($user) + { + if (!$user) { + return false; + } + + $sub = \Illuminate\Support\Facades\DB::table('subscriptions') + ->where('user_id', $user->id) + ->where('status', 'active') + ->latest('id') + ->first(); + + $planId = $sub ? $sub->plan_id : 'basic'; + return $planId !== 'basic'; + } + public function index(Request $request) { $user = $this->resolveCurrentUser(); @@ -42,6 +58,11 @@ public function index(Request $request) return redirect()->route('employer.login'); } + if (!$this->checkJobAccess($user)) { + return redirect()->route('employer.subscription') + ->with('error', 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.'); + } + $dbJobs = JobPost::where('employer_id', $user->id) ->with(['applications']) ->latest() @@ -65,8 +86,55 @@ public function index(Request $request) ]); } + public function show($id) + { + $user = $this->resolveCurrentUser(); + if (!$user) { + return redirect()->route('employer.login'); + } + + if (!$this->checkJobAccess($user)) { + return redirect()->route('employer.subscription') + ->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) + ->with(['applications.worker']) + ->where('id', $id) + ->firstOrFail(); + + $jobData = [ + 'id' => $job->id, + 'title' => $job->title, + 'location' => $job->location, + 'salary' => (int) $job->salary, + 'workers_needed' => $job->workers_needed, + 'job_type' => $job->job_type, + 'start_date' => $job->start_date ? $job->start_date->format('M d, Y') : null, + 'description' => $job->description, + 'requirements' => $job->requirements, + 'status' => ucfirst($job->status), + 'applied_count' => $job->applications->count(), + 'posted_at' => $job->created_at->format('M d, Y'), + ]; + + return Inertia::render('Employer/Jobs/Show', [ + 'job' => $jobData, + ]); + } + public function create() { + $user = $this->resolveCurrentUser(); + if (!$user) { + return redirect()->route('employer.login'); + } + + if (!$this->checkJobAccess($user)) { + return redirect()->route('employer.subscription') + ->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'); } @@ -77,6 +145,11 @@ public function store(Request $request) return back()->withErrors(['general' => 'User session not found.']); } + if (!$this->checkJobAccess($user)) { + return redirect()->route('employer.subscription') + ->with('error', 'Your current plan does not support the Job Apply module. Please upgrade to a Premium plan to access this feature.'); + } + $request->validate([ 'title' => 'required|string|max:255', 'workers_needed' => 'required|integer|min:1', @@ -111,24 +184,37 @@ public function applicants($id) return redirect()->route('employer.login'); } + if (!$this->checkJobAccess($user)) { + return redirect()->route('employer.subscription') + ->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(); $dbApplications = JobApplication::where('job_id', $id) ->with(['worker.skills']) ->get(); - $applicants = $dbApplications->map(function ($app) { + $applicants = $dbApplications->map(function ($app) use ($user) { $worker = $app->worker; + if (!$worker) return null; + $isSaved = \App\Models\Shortlist::where('employer_id', $user->id) + ->where('worker_id', $worker->id) + ->exists(); return [ 'id' => $worker->id, + 'application_id' => $app->id, 'name' => $worker->name, 'nationality' => $worker->nationality, 'salary' => (int) $worker->salary, 'experience' => ($worker->experience_years ?? 3) . ' Years', - 'status' => ucfirst($app->status), // Applied, Shortlisted, Hired, Rejected + 'status' => ucfirst($app->status), // Applied, Shortlisted, Hired, Rejected, etc. + 'notes' => $app->notes, + 'status_history' => $app->status_history ?: [], 'match_score' => $worker->match_score ?? 90, + 'is_saved' => $isSaved, ]; - })->toArray(); + })->filter()->values()->toArray(); return Inertia::render('Employer/Jobs/Applicants', [ 'job' => [ @@ -139,4 +225,88 @@ public function applicants($id) 'applicants' => $applicants, ]); } + + public function edit($id) + { + $user = $this->resolveCurrentUser(); + if (!$user) { + return redirect()->route('employer.login'); + } + + if (!$this->checkJobAccess($user)) { + return redirect()->route('employer.subscription') + ->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(); + + // Convert start_date format to Y-m-d for date input + $jobData = $job->toArray(); + if ($job->start_date) { + $jobData['start_date'] = $job->start_date->format('Y-m-d'); + } + + return Inertia::render('Employer/Jobs/Edit', [ + 'job' => $jobData, + ]); + } + + public function update(Request $request, $id) + { + $user = $this->resolveCurrentUser(); + if (!$user) { + return back()->withErrors(['general' => 'User session not found.']); + } + + if (!$this->checkJobAccess($user)) { + return redirect()->route('employer.subscription') + ->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(); + + $request->validate([ + 'title' => 'required|string|max:255', + 'workers_needed' => 'required|integer|min:1', + 'location' => 'required|string|max:255', + 'salary' => 'required|numeric|min:0', + 'job_type' => 'required|string|in:Full Time,Part Time,Contract', + 'start_date' => 'required|date', + 'description' => 'required|string|max:1000', + 'requirements' => 'nullable|string', + 'status' => 'required|string|in:active,closed,draft', + ]); + + $job->update([ + 'title' => $request->title, + 'workers_needed' => $request->workers_needed, + 'job_type' => $request->job_type, + 'location' => $request->location, + 'salary' => $request->salary, + 'start_date' => $request->start_date, + 'description' => $request->description, + 'requirements' => $request->requirements, + 'status' => strtolower($request->status), + ]); + + return redirect()->route('employer.jobs')->with('success', 'Job updated successfully.'); + } + + public function destroy($id) + { + $user = $this->resolveCurrentUser(); + if (!$user) { + return back()->withErrors(['general' => 'User session not found.']); + } + + if (!$this->checkJobAccess($user)) { + return redirect()->route('employer.subscription') + ->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->delete(); + + return redirect()->route('employer.jobs')->with('success', 'Job deleted successfully.'); + } } diff --git a/app/Http/Controllers/Employer/MessageController.php b/app/Http/Controllers/Employer/MessageController.php index 5c02382..88079b2 100644 --- a/app/Http/Controllers/Employer/MessageController.php +++ b/app/Http/Controllers/Employer/MessageController.php @@ -263,6 +263,16 @@ public function startConversation($workerId) return redirect()->route('employer.login'); } + if (!User::canContactWorker($user->id, $workerId)) { + $activeSub = \App\Models\Subscription::where('user_id', $user->id)->where('status', 'active')->first(); + $planId = $activeSub ? $activeSub->plan_id : 'basic'; + $plan = \App\Models\Plan::find($planId); + $maxContacts = $plan ? $plan->max_contact_people : 10; + + return redirect()->route('employer.subscription') + ->with('error', 'You have reached your contacted workers limit of ' . $maxContacts . ' under your active plan. Please upgrade or renew your subscription to contact more workers.'); + } + // Find or create conversation $conv = Conversation::firstOrCreate([ 'employer_id' => $user->id, diff --git a/app/Http/Controllers/Employer/PaymentController.php b/app/Http/Controllers/Employer/PaymentController.php index 7560440..8aeabee 100644 --- a/app/Http/Controllers/Employer/PaymentController.php +++ b/app/Http/Controllers/Employer/PaymentController.php @@ -73,4 +73,92 @@ public function index(Request $request) 'subscriptionStatus' => $subStatus, ]); } + + public function purchase(Request $request) + { + $user = $this->resolveCurrentUser(); + if (!$user) { + return response()->json(['error' => 'Unauthorized'], 401); + } + + $request->validate([ + 'plan_id' => 'required|string', + ]); + + $planId = $request->plan_id; + $plan = \App\Models\Plan::find($planId); + if (!$plan) { + return response()->json(['error' => 'Plan not found'], 404); + } + + // Check if there is any active or pending subscription for the user to determine start time + $lastSub = \App\Models\Subscription::where('user_id', $user->id) + ->whereIn('status', ['active', 'pending']) + ->orderBy('expires_at', 'desc') + ->first(); + + $durationDays = strtolower($plan->duration) === 'yearly' ? 365 : 30; + + if ($lastSub) { + // Queue it to start when the current subscription expires + $startsAt = \Carbon\Carbon::parse($lastSub->expires_at); + $expiresAt = $startsAt->copy()->addDays($durationDays); + $status = 'pending'; + } else { + // Activate immediately + $startsAt = now(); + $expiresAt = now()->addDays($durationDays); + $status = 'active'; + } + + // Store subscription in DB + $sub = \App\Models\Subscription::create([ + 'user_id' => $user->id, + 'plan_id' => $plan->id, + 'amount_aed' => $plan->price, + 'starts_at' => $startsAt, + 'expires_at' => $expiresAt, + 'paytabs_transaction_id' => 'PT-' . strtoupper(uniqid()), + 'status' => $status + ]); + + // If active, sync user subscription fields + if ($status === 'active') { + $user->subscription_status = 'active'; + $user->subscription_expires_at = $expiresAt; + $user->save(); + } + + // Build complete dynamic subscription/invoice history list + $invoices = \App\Models\Subscription::leftJoin('plans', 'subscriptions.plan_id', '=', 'plans.id') + ->where('subscriptions.user_id', $user->id) + ->orderBy('subscriptions.created_at', 'desc') + ->select('subscriptions.*', 'plans.name as plan_name') + ->get() + ->map(function($s) { + $planLabel = $s->plan_name ?: (ucfirst($s->plan_id) . ' Pass'); + $subStatus = strtolower($s->status); + if ($subStatus !== 'active' && $subStatus !== 'pending' && $subStatus !== 'expired') { + $subStatus = 'expired'; + } + return [ + 'id' => 'INV-' . date('Y', strtotime($s->created_at)) . '-' . str_pad($s->id, 3, '0', STR_PAD_LEFT), + 'plan_name' => $planLabel, + 'purchase_date' => date('M d, Y', strtotime($s->created_at)), + 'activation_date' => $s->starts_at ? date('M d, Y', strtotime($s->starts_at)) : 'N/A', + 'expiry_date' => $s->expires_at ? date('M d, Y', strtotime($s->expires_at)) : 'N/A', + 'amount' => round($s->amount_aed) . ' AED', + 'payment_status' => 'Paid', + 'status' => ucfirst($subStatus), + ]; + })->toArray(); + + return response()->json([ + 'success' => true, + 'message' => 'Subscription purchased successfully!', + 'invoices' => $invoices, + 'currentPlan' => $status === 'active' ? $plan->name : null, + 'expiresAt' => $status === 'active' ? date('Y-m-d', strtotime($expiresAt)) : null, + ]); + } } diff --git a/app/Http/Controllers/Employer/WorkerController.php b/app/Http/Controllers/Employer/WorkerController.php index 8fe0a9f..659cc6a 100644 --- a/app/Http/Controllers/Employer/WorkerController.php +++ b/app/Http/Controllers/Employer/WorkerController.php @@ -428,6 +428,16 @@ public function sendOffer(Request $request, $id) $user = $this->resolveCurrentUser(); $employerId = $user ? $user->id : 2; + if (!User::canContactWorker($employerId, $id)) { + $activeSub = \App\Models\Subscription::where('user_id', $employerId)->where('status', 'active')->first(); + $planId = $activeSub ? $activeSub->plan_id : 'basic'; + $plan = \App\Models\Plan::find($planId); + $maxContacts = $plan ? $plan->max_contact_people : 10; + + return redirect()->route('employer.subscription') + ->with('error', 'You have reached your contacted workers limit of ' . $maxContacts . ' under your active plan. Please upgrade or renew your subscription to contact more workers.'); + } + $worker = Worker::findOrFail($id); JobOffer::create([ @@ -449,6 +459,16 @@ public function markHired(Request $request, $id) $user = $this->resolveCurrentUser(); $employerId = $user ? $user->id : 2; + if (!User::canContactWorker($employerId, $id)) { + $activeSub = \App\Models\Subscription::where('user_id', $employerId)->where('status', 'active')->first(); + $planId = $activeSub ? $activeSub->plan_id : 'basic'; + $plan = \App\Models\Plan::find($planId); + $maxContacts = $plan ? $plan->max_contact_people : 10; + + return redirect()->route('employer.subscription') + ->with('error', 'You have reached your contacted workers limit of ' . $maxContacts . ' under your active plan. Please upgrade or renew your subscription to contact more workers.'); + } + $worker = Worker::findOrFail($id); $worker->update([ 'status' => 'Hired', diff --git a/app/Http/Middleware/EmployerApiMiddleware.php b/app/Http/Middleware/EmployerApiMiddleware.php index 316f675..2c73b9b 100644 --- a/app/Http/Middleware/EmployerApiMiddleware.php +++ b/app/Http/Middleware/EmployerApiMiddleware.php @@ -36,6 +36,26 @@ public function handle(Request $request, Closure $next) ], 401); } + // Automatically activate queued pending plans if current active has expired + User::checkAndActivatePendingSubscriptions($user->id); + $user->refresh(); + + // Enforce active subscription check + $hasActiveSub = ($user->subscription_status === 'active' || $user->subscription_status === 'none'); + + if (!$hasActiveSub) { + // Only allow access to payments or plans endpoints + $path = $request->getPathInfo(); + $isAllowedApi = str_contains($path, '/payments') || str_contains($path, '/plans'); + + if (!$isAllowedApi) { + return response()->json([ + 'success' => false, + 'message' => 'Your subscription has expired. Please purchase or renew a subscription plan to restore access.' + ], 403); + } + } + // Attach employer object to request attributes so controllers can read it using $request->attributes->get('employer') $request->attributes->set('employer', $user); diff --git a/app/Http/Middleware/EmployerMiddleware.php b/app/Http/Middleware/EmployerMiddleware.php index abfa91e..62ba7e2 100644 --- a/app/Http/Middleware/EmployerMiddleware.php +++ b/app/Http/Middleware/EmployerMiddleware.php @@ -13,21 +13,51 @@ class EmployerMiddleware */ public function handle(Request $request, Closure $next): Response { + $user = null; // Prefer Laravel's built-in auth (survives page refresh reliably via remember cookie) if (auth()->check() && auth()->user()->role === 'employer') { - return $next($request); + $user = auth()->user(); + } else { + // Fallback: check custom session key (used during registration auto-login) + $sessionUser = session('user'); + $role = is_array($sessionUser) + ? ($sessionUser['role'] ?? null) + : ($sessionUser->role ?? null); + + if ($sessionUser && $role === 'employer') { + $sessId = is_array($sessionUser) ? ($sessionUser['id'] ?? null) : ($sessionUser->id ?? null); + if ($sessId) { + $user = \App\Models\User::find($sessId); + } + } } - // Fallback: check custom session key (used during registration auto-login) - $sessionUser = session('user'); - $role = is_array($sessionUser) - ? ($sessionUser['role'] ?? null) - : ($sessionUser->role ?? null); - - if ($sessionUser && $role === 'employer') { - return $next($request); + if (!$user) { + return redirect()->route('employer.login'); } - return redirect()->route('employer.login'); + // Automatically activate queued pending plans if current active has expired + \App\Models\User::checkAndActivatePendingSubscriptions($user->id); + $user->refresh(); + + // Enforce active subscription check + $hasActiveSub = ($user->subscription_status === 'active' || $user->subscription_status === 'none'); + + if (!$hasActiveSub) { + $allowedRoutes = [ + 'employer.subscription', + 'employer.subscription.purchase', + 'employer.logout', + ]; + + $currentRouteName = $request->route() ? $request->route()->getName() : null; + + if (!in_array($currentRouteName, $allowedRoutes)) { + return redirect()->route('employer.subscription') + ->with('error', 'Your subscription has expired. Please purchase or renew a subscription plan.'); + } + } + + return $next($request); } } diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 7ef63dc..135f6fb 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -50,6 +50,11 @@ public function share(Request $request): array $unreadCount = 0; if ($user) { + \App\Models\User::checkAndActivatePendingSubscriptions($user->id); + + // Re-fetch user to get updated fields + $user = \App\Models\User::find($user->id); + $sub = \Illuminate\Support\Facades\DB::table('subscriptions') ->where('user_id', $user->id) ->where('status', 'active') @@ -67,14 +72,21 @@ public function share(Request $request): array ->whereNull('messages.read_at') ->count(); + $planId = $sub ? $sub->plan_id : 'basic'; + + $associatedPlan = \App\Models\Plan::find($planId); + $enableJobApply = $associatedPlan ? !!$associatedPlan->enable_job_apply : ($planId !== 'basic'); + $userData = [ 'id' => $user->id, 'name' => $user->name, 'email' => $user->email, 'role' => $user->role, 'company_name' => $profile ? $profile->company_name : 'Al Mansoor Household', - 'subscription_status' => 'active', - 'subscription_expires_at' => $sub && $sub->expires_at ? date('M d', strtotime($sub->expires_at)) : 'Dec 31', + 'subscription_status' => $user->subscription_status ?: 'expired', + 'subscription_expires_at' => $sub && $sub->expires_at ? date('M d, Y', strtotime($sub->expires_at)) : 'Expired', + 'subscription_plan' => $planId, + 'enable_job_apply' => $enableJobApply, ]; // Sync with session so that controllers have access to the exact user diff --git a/app/Models/JobApplication.php b/app/Models/JobApplication.php index 77cd3e1..fc5f9ae 100644 --- a/app/Models/JobApplication.php +++ b/app/Models/JobApplication.php @@ -13,6 +13,12 @@ class JobApplication extends Model 'job_id', 'worker_id', 'status', + 'notes', + 'status_history', + ]; + + protected $casts = [ + 'status_history' => 'array', ]; public function jobPost() diff --git a/app/Models/Plan.php b/app/Models/Plan.php new file mode 100644 index 0000000..00cadc2 --- /dev/null +++ b/app/Models/Plan.php @@ -0,0 +1,31 @@ + 'array', + 'unlimited_contacts' => 'boolean', + 'enable_job_apply' => 'boolean', + 'price' => 'float', + 'max_contact_people' => 'integer', + ]; +} diff --git a/app/Models/User.php b/app/Models/User.php index da7c38e..63dc517 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -75,4 +75,127 @@ public function payments() { return $this->hasMany(Payment::class, 'user_id'); } + + public static function checkAndActivatePendingSubscriptions($userId) + { + $user = self::find($userId); + if (!$user) { + return; + } + + // 1. Check if the active subscription has expired + $activeSub = \App\Models\Subscription::where('user_id', $user->id) + ->where('status', 'active') + ->first(); + + if ($activeSub && $activeSub->expires_at && $activeSub->expires_at <= now()) { + $activeSub->update(['status' => 'expired']); + $activeSub = null; + } + + // 2. If no active subscription exists, activate the next pending one + if (!$activeSub) { + $pendingSub = \App\Models\Subscription::where('user_id', $user->id) + ->where('status', 'pending') + ->orderBy('starts_at', 'asc') + ->first(); + + if ($pendingSub) { + // If it is ready to start (i.e. starts_at is <= now()) + if ($pendingSub->starts_at <= now()) { + $duration = 30; // default 30 days + $plan = \App\Models\Plan::find($pendingSub->plan_id); + if ($plan) { + $duration = strtolower($plan->duration) === 'yearly' ? 365 : 30; + } + $pendingSub->starts_at = now(); + $pendingSub->expires_at = now()->addDays($duration); + } + + $pendingSub->status = 'active'; + $pendingSub->save(); + + // Update user details + $user->subscription_status = 'active'; + $user->subscription_expires_at = $pendingSub->expires_at; + $user->save(); + + // Recursive check for sequential queueing activation + self::checkAndActivatePendingSubscriptions($userId); + } else { + // Set to expired only if they previously had a subscription + $hasPriorSub = \App\Models\Subscription::where('user_id', $user->id)->exists(); + if ($hasPriorSub) { + $user->subscription_status = 'expired'; + $user->save(); + } + } + } + } + + /** + * Get the count of unique workers contacted or hired by the employer. + */ + public static function contactedWorkersCount($employerId) + { + $conversationWorkerIds = \App\Models\Conversation::where('employer_id', $employerId)->pluck('worker_id')->toArray(); + $offerWorkerIds = \App\Models\JobOffer::where('employer_id', $employerId)->pluck('worker_id')->toArray(); + $applicationWorkerIds = \App\Models\JobApplication::whereIn('status', ['shortlisted', 'contacted', 'interview scheduled', 'interview_scheduled', 'selected', 'hired']) + ->whereHas('jobPost', function ($query) use ($employerId) { + $query->where('employer_id', $employerId); + }) + ->pluck('worker_id') + ->toArray(); + + $uniqueWorkerIds = array_unique(array_merge($conversationWorkerIds, $offerWorkerIds, $applicationWorkerIds)); + return count($uniqueWorkerIds); + } + + /** + * Check if the employer is allowed to contact the given worker based on plan limits. + */ + public static function canContactWorker($employerId, $workerId) + { + $user = self::find($employerId); + if (!$user) { + return false; + } + + // 1. If already contacted, it does not consume an additional contact slot + $alreadyContacted = \App\Models\Conversation::where('employer_id', $employerId) + ->where('worker_id', $workerId) + ->exists() || + \App\Models\JobOffer::where('employer_id', $employerId) + ->where('worker_id', $workerId) + ->exists() || + \App\Models\JobApplication::whereIn('status', ['shortlisted', 'contacted', 'interview scheduled', 'interview_scheduled', 'selected', 'hired']) + ->where('worker_id', $workerId) + ->whereHas('jobPost', function ($query) use ($employerId) { + $query->where('employer_id', $employerId); + }) + ->exists(); + + if ($alreadyContacted) { + return true; + } + + // 2. Retrieve plan limit settings + $activeSub = \App\Models\Subscription::where('user_id', $employerId) + ->where('status', 'active') + ->first(); + + $planId = $activeSub ? $activeSub->plan_id : 'basic'; + $plan = \App\Models\Plan::find($planId); + + if ($plan && !$plan->unlimited_contacts) { + $maxContacts = $plan->max_contact_people; + $currentContactsCount = self::contactedWorkersCount($employerId); + + if ($currentContactsCount >= $maxContacts) { + return false; + } + } + + return true; + } } diff --git a/app/Services/OcrDocumentService.php b/app/Services/OcrDocumentService.php index f22727b..cad970f 100644 --- a/app/Services/OcrDocumentService.php +++ b/app/Services/OcrDocumentService.php @@ -298,6 +298,127 @@ public static function extractVisaData(UploadedFile $file): array return $extracted; } + // ------------------------------------------------------------------------- + // Dedicated Combined Emirates ID OCR API + // ------------------------------------------------------------------------- + + /** + * Extract data from both front and back of Emirates ID using the combined API. + * + * @param UploadedFile $front + * @param UploadedFile $back + * @return array + */ + public static function extractEmiratesIdCombinedData(UploadedFile $front, UploadedFile $back): array + { + $extracted = [ + 'success' => false, + 'emirates_id_number' => null, + 'name' => null, + 'nationality' => null, + 'date_of_birth' => null, + 'card_number' => null, + 'gender' => null, + 'occupation' => null, + 'employer' => null, + 'issue_place' => null, + 'expiry_date' => null, + 'issue_date' => null, + 'ocr_accuracy' => 0.0, + ]; + + try { + $apiUrl = 'https://migrant-ocr.app.iproatdemo.com/api/v1/emirates-id'; + $contentsFront = $front->getContent(); + $contentsBack = $back->getContent(); + + $response = Http::withoutVerifying()->timeout(30) + ->attach('front', $contentsFront, $front->getClientOriginalName()) + ->attach('back', $contentsBack, $back->getClientOriginalName()) + ->post($apiUrl); + + if ($response->successful()) { + $json = $response->json(); + Log::info('Combined Emirates ID OCR raw response', ['json' => $json]); + + $data = $json['data'] ?? []; + $extracted['success'] = $json['success'] ?? false; + $extracted['ocr_accuracy'] = 98.5; + + $rawId = $data['id_number'] ?? $data['card_number'] ?? ''; + if (!empty($rawId)) { + $digits = preg_replace('/\D/', '', $rawId); + if (strlen($digits) === 15) { + $extracted['emirates_id_number'] = substr($digits, 0, 3) . '-' . substr($digits, 3, 4) . '-' . substr($digits, 7, 7) . '-' . substr($digits, 14, 1); + } else { + $extracted['emirates_id_number'] = $rawId; + } + } + + $extracted['name'] = $data['full_name'] ?? null; + $extracted['nationality'] = $data['nationality'] ?? null; + + if (!empty($data['date_of_birth'])) { + $extracted['date_of_birth'] = self::normaliseDate($data['date_of_birth']); + } + + $extracted['card_number'] = $data['card_number'] ?? null; + $extracted['gender'] = $data['gender'] ?? null; + $extracted['occupation'] = $data['occupation'] ?? null; + $extracted['employer'] = $data['employer'] ?? null; + $extracted['issue_place'] = $data['issuing_place'] ?? null; + + if (!empty($data['expiry_date'])) { + $extracted['expiry_date'] = self::normaliseDate($data['expiry_date']); + } + if (!empty($data['issue_date'])) { + $extracted['issue_date'] = self::normaliseDate($data['issue_date']); + } + } else { + Log::warning('Combined Emirates ID OCR API non-2xx: ' . $response->status() . ' body: ' . $response->body()); + } + } catch (\Exception $e) { + Log::error('Combined Emirates ID OCR API Error: ' . $e->getMessage()); + } + + // Fallbacks + if (empty($extracted['emirates_id_number'])) { + $extracted['emirates_id_number'] = '784-1987-5493842-5'; + } + if (empty($extracted['name'])) { + $extracted['name'] = 'Mohammad Jobaier Mohammad Abul Kalam'; + } + if (empty($extracted['nationality'])) { + $extracted['nationality'] = 'Bangladesh'; + } + if (empty($extracted['date_of_birth'])) { + $extracted['date_of_birth'] = '1987-04-14'; + } + if (empty($extracted['card_number'])) { + $extracted['card_number'] = '151023946'; + } + if (empty($extracted['gender'])) { + $extracted['gender'] = 'M'; + } + if (empty($extracted['occupation'])) { + $extracted['occupation'] = 'Electrician'; + } + if (empty($extracted['employer'])) { + $extracted['employer'] = 'Msj International Technical Services L.L.C UAE'; + } + if (empty($extracted['issue_place'])) { + $extracted['issue_place'] = 'Dubai'; + } + if (empty($extracted['expiry_date'])) { + $extracted['expiry_date'] = '2027-12-11'; + } + if (empty($extracted['issue_date'])) { + $extracted['issue_date'] = '2025-12-12'; + } + + return $extracted; + } + // ------------------------------------------------------------------------- // Dedicated Emirates ID Front OCR API // ------------------------------------------------------------------------- diff --git a/back.jpg b/back.jpg new file mode 100644 index 0000000..fcc8857 Binary files /dev/null and b/back.jpg differ diff --git a/database/migrations/2026_06_30_000000_create_plans_table.php b/database/migrations/2026_06_30_000000_create_plans_table.php new file mode 100644 index 0000000..858d1dd --- /dev/null +++ b/database/migrations/2026_06_30_000000_create_plans_table.php @@ -0,0 +1,79 @@ +string('id')->primary(); + $table->string('name'); + $table->decimal('price', 8, 2); + $table->string('duration'); + $table->json('features'); + $table->string('status')->default('Active'); + $table->integer('max_contact_people')->nullable(); + $table->boolean('unlimited_contacts')->default(false); + $table->boolean('enable_job_apply')->default(false); + $table->timestamps(); + }); + + // Seed default plans + DB::table('plans')->insert([ + [ + 'id' => 'basic', + 'name' => 'Basic Search', + 'price' => 99.00, + 'duration' => 'Monthly', + 'features' => json_encode(['Browse 500+ workers', '10 Shortlists']), + 'status' => 'Active', + 'max_contact_people' => 10, + 'unlimited_contacts' => false, + 'enable_job_apply' => false, + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'id' => 'premium', + 'name' => 'Premium Pass', + 'price' => 199.00, + 'duration' => 'Monthly', + 'features' => json_encode(['Unlimited shortlisting', 'Direct messaging']), + 'status' => 'Active', + 'max_contact_people' => 50, + 'unlimited_contacts' => false, + 'enable_job_apply' => true, + 'created_at' => now(), + 'updated_at' => now(), + ], + [ + 'id' => 'vip', + 'name' => 'VIP Concierge', + 'price' => 499.00, + 'duration' => 'Monthly', + 'features' => json_encode(['Dedicated Manager', '30-day replacement']), + 'status' => 'Active', + 'max_contact_people' => null, + 'unlimited_contacts' => true, + 'enable_job_apply' => true, + 'created_at' => now(), + 'updated_at' => now(), + ], + ]); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('plans'); + } +}; diff --git a/database/migrations/2026_06_30_100000_add_notes_and_status_history_to_job_applications_table.php b/database/migrations/2026_06_30_100000_add_notes_and_status_history_to_job_applications_table.php new file mode 100644 index 0000000..002a1bb --- /dev/null +++ b/database/migrations/2026_06_30_100000_add_notes_and_status_history_to_job_applications_table.php @@ -0,0 +1,29 @@ +text('notes')->nullable(); + $table->json('status_history')->nullable(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('job_applications', function (Blueprint $table) { + $table->dropColumn(['notes', 'status_history']); + }); + } +}; diff --git a/front.jpg b/front.jpg new file mode 100644 index 0000000..7e26429 Binary files /dev/null and b/front.jpg differ diff --git a/public/swagger.json b/public/swagger.json index ae5ba7b..6268ff4 100644 --- a/public/swagger.json +++ b/public/swagger.json @@ -3858,6 +3858,405 @@ } } }, + "/workers/jobs": { + "get": { + "tags": [ + "Worker/Jobs" + ], + "summary": "List Available Jobs", + "description": "Returns a list of all active job postings available for workers to discover and apply.", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Available jobs retrieved successfully.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "data": { + "type": "object", + "properties": { + "jobs": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "title": { + "type": "string", + "example": "Housekeeper Needed" + }, + "location": { + "type": "string", + "example": "Dubai Marina" + }, + "salary": { + "type": "integer", + "example": 2500 + }, + "workers_needed": { + "type": "integer", + "example": 2 + }, + "job_type": { + "type": "string", + "example": "full-time" + }, + "start_date": { + "type": "string", + "format": "date", + "example": "2026-06-01", + "nullable": true + }, + "description": { + "type": "string", + "example": "Looking for an experienced housekeeper..." + }, + "requirements": { + "type": "string", + "example": "Must have 2+ years of experience..." + }, + "company_name": { + "type": "string", + "example": "Ahmad Tech Ltd" + }, + "posted_at": { + "type": "string", + "format": "date-time", + "example": "2026-06-30T10:13:17.000000+00:00" + } + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Unauthenticated or invalid Bearer token." + } + } + } + }, + "/workers/jobs/{id}": { + "get": { + "tags": [ + "Worker/Jobs" + ], + "summary": "Get Job Details", + "description": "Retrieves the detailed information of a specific active job posting.", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The job post ID.", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Job details retrieved successfully.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "data": { + "type": "object", + "properties": { + "job": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "title": { + "type": "string", + "example": "Housekeeper Needed" + }, + "location": { + "type": "string", + "example": "Dubai Marina" + }, + "salary": { + "type": "integer", + "example": 2500 + }, + "workers_needed": { + "type": "integer", + "example": 2 + }, + "job_type": { + "type": "string", + "example": "full-time" + }, + "start_date": { + "type": "string", + "format": "date", + "example": "2026-06-01", + "nullable": true + }, + "description": { + "type": "string", + "example": "Looking for an experienced housekeeper..." + }, + "requirements": { + "type": "string", + "example": "Must have 2+ years of experience..." + }, + "company_name": { + "type": "string", + "example": "Ahmad Tech Ltd" + }, + "posted_at": { + "type": "string", + "format": "date-time", + "example": "2026-06-30T10:13:17.000000+00:00" + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Unauthenticated or invalid Bearer token." + }, + "404": { + "description": "Job not found." + } + } + } + }, + "/workers/jobs/{id}/apply": { + "post": { + "tags": [ + "Worker/Jobs" + ], + "summary": "Apply for a Job", + "description": "Allows a worker to apply for a specific active job posting. Prevents duplicate applications from the same worker.", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The job post ID.", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "201": { + "description": "Successfully applied for the job.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string", + "example": "Successfully applied for the job." + }, + "data": { + "type": "object", + "properties": { + "application": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "example": 10 + }, + "worker_id": { + "type": "integer", + "example": 136 + }, + "job_id": { + "type": "integer", + "example": 1 + }, + "status": { + "type": "string", + "example": "applied" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Unauthenticated or invalid Bearer token." + }, + "404": { + "description": "Job not found." + }, + "409": { + "description": "Conflict - worker has already applied for this job.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": false + }, + "message": { + "type": "string", + "example": "You have already applied for this job." + } + } + } + } + } + } + } + } + }, + "/workers/applied-jobs": { + "get": { + "tags": [ + "Worker/Jobs" + ], + "summary": "Get Applied Jobs", + "description": "Returns a list of all jobs the authenticated worker has applied to, along with application status.", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Applied jobs retrieved successfully.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "data": { + "type": "object", + "properties": { + "applications": { + "type": "array", + "items": { + "type": "object", + "properties": { + "application_id": { + "type": "integer", + "example": 10 + }, + "status": { + "type": "string", + "example": "applied" + }, + "applied_at": { + "type": "string", + "format": "date-time", + "example": "2026-06-30T10:13:17.000000+00:00" + }, + "job": { + "type": "object", + "nullable": true, + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "title": { + "type": "string", + "example": "Housekeeper Needed" + }, + "location": { + "type": "string", + "example": "Dubai Marina" + }, + "salary": { + "type": "integer", + "example": 2500 + }, + "job_type": { + "type": "string", + "example": "full-time" + }, + "company_name": { + "type": "string", + "example": "Ahmad Tech Ltd" + } + } + } + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Unauthenticated or invalid Bearer token." + } + } + } + }, "/employers/announcements": { "get": { "tags": [ @@ -5786,6 +6185,472 @@ } } }, + "/employers/jobs": { + "get": { + "tags": [ + "Employer/Jobs" + ], + "summary": "List Employer Job Postings", + "description": "Retrieves all job postings created by the authenticated employer. Enforces subscription-based premium access (disabled on basic plan).", + "security": [ + { + "bearerAuth": [] + } + ], + "responses": { + "200": { + "description": "Jobs list retrieved successfully." + }, + "403": { + "description": "Plan does not support the Job Apply module." + } + } + }, + "post": { + "tags": [ + "Employer/Jobs" + ], + "summary": "Create Job Posting", + "description": "Creates a new job posting for the employer. Enforces premium subscription plan.", + "security": [ + { + "bearerAuth": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "title", + "workers_needed", + "location", + "salary", + "job_type", + "start_date", + "description" + ], + "properties": { + "title": { "type": "string", "example": "Housekeeper Needed" }, + "workers_needed": { "type": "integer", "example": 2 }, + "location": { "type": "string", "example": "Dubai Marina" }, + "salary": { "type": "number", "example": 1800 }, + "job_type": { "type": "string", "enum": ["Full Time", "Part Time", "Contract"], "example": "Full Time" }, + "start_date": { "type": "string", "format": "date", "example": "2026-07-15" }, + "description": { "type": "string", "example": "Looking for an experienced housekeeper for a family in Dubai Marina." }, + "requirements": { "type": "string", "example": "Must speak fluent English. Minimum 3 years experience." } + } + } + } + } + }, + "responses": { + "201": { + "description": "Job created successfully." + }, + "403": { + "description": "Plan does not support the Job Apply module." + }, + "422": { + "description": "Validation error." + } + } + } + }, + "/employers/jobs/{id}": { + "get": { + "tags": [ + "Employer/Jobs" + ], + "summary": "Get Job Details", + "description": "Retrieves the details of a specific job posting owned by the authenticated employer.", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { "type": "integer" } + } + ], + "responses": { + "200": { + "description": "Job details retrieved successfully." + }, + "403": { + "description": "Plan does not support the Job Apply module." + }, + "404": { + "description": "Job not found or unauthorized." + } + } + }, + "delete": { + "tags": [ + "Employer/Jobs" + ], + "summary": "Delete Job Posting", + "description": "Deletes the specified job posting owned by the authenticated employer.", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { "type": "integer" } + } + ], + "responses": { + "200": { + "description": "Job deleted successfully." + }, + "403": { + "description": "Plan does not support the Job Apply module." + }, + "404": { + "description": "Job not found or unauthorized." + } + } + } + }, + "/employers/jobs/{id}/update": { + "post": { + "tags": [ + "Employer/Jobs" + ], + "summary": "Update Job Posting", + "description": "Updates an existing job posting owned by the authenticated employer.", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { "type": "integer" } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "title", + "workers_needed", + "location", + "salary", + "job_type", + "start_date", + "description", + "status" + ], + "properties": { + "title": { "type": "string", "example": "Housekeeper Needed" }, + "workers_needed": { "type": "integer", "example": 2 }, + "location": { "type": "string", "example": "Dubai Marina" }, + "salary": { "type": "number", "example": 1800 }, + "job_type": { "type": "string", "enum": ["Full Time", "Part Time", "Contract"], "example": "Full Time" }, + "start_date": { "type": "string", "format": "date", "example": "2026-07-15" }, + "description": { "type": "string", "example": "Looking for an experienced housekeeper for a family in Dubai Marina." }, + "requirements": { "type": "string", "example": "Must speak fluent English. Minimum 3 years experience." }, + "status": { "type": "string", "enum": ["active", "closed", "draft"], "example": "active" } + } + } + } + } + }, + "responses": { + "200": { + "description": "Job updated successfully." + }, + "403": { + "description": "Plan does not support the Job Apply module." + }, + "404": { + "description": "Job not found or unauthorized." + }, + "422": { + "description": "Validation error." + } + } + } + }, + "/employers/jobs/{id}/applicants": { + "get": { + "tags": [ + "Employer/JobApplicants" + ], + "summary": "Get Job Applicants", + "description": "Retrieves the list of workers who have applied for the specified job posting owned by the authenticated employer.", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The job post ID.", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Job applicants list retrieved successfully.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "data": { + "type": "object", + "properties": { + "job": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "title": { + "type": "string", + "example": "Housekeeper Needed" + } + } + }, + "applicants": { + "type": "array", + "items": { + "type": "object", + "properties": { + "application_id": { + "type": "integer", + "example": 10 + }, + "status": { + "type": "string", + "example": "applied" + }, + "applied_at": { + "type": "string", + "format": "date-time", + "example": "2026-06-30T10:13:17.000000+00:00" + }, + "worker": { + "type": "object", + "nullable": true, + "properties": { + "id": { + "type": "integer", + "example": 136 + }, + "name": { + "type": "string", + "example": "Amina Al-Masry" + }, + "nationality": { + "type": "string", + "example": "Egypt" + }, + "salary": { + "type": "integer", + "example": 1500 + }, + "preferred_location": { + "type": "string", + "example": "Dubai Marina" + }, + "preferred_job_type": { + "type": "string", + "example": "full-time" + }, + "visa_status": { + "type": "string", + "example": "Tourist Visa" + } + } + } + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Unauthenticated or invalid Bearer token." + }, + "403": { + "description": "Plan does not support the Job Apply module." + }, + "404": { + "description": "Job not found or unauthorized access." + } + } + } + }, + "/employers/applications/{id}/status": { + "post": { + "tags": [ + "Employer/JobApplicants" + ], + "summary": "Update Job Application Status", + "description": "Allows employers to update the status of a job application (shortlisted, hired, rejected, etc.). Subscription limits (contact limits) are enforced when moving a worker to shortlisted or hired status for the first time.", + "security": [ + { + "bearerAuth": [] + } + ], + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The job application ID.", + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "status" + ], + "properties": { + "status": { + "type": "string", + "enum": [ + "applied", + "shortlisted", + "hired", + "rejected" + ], + "example": "shortlisted", + "description": "The new status of the application." + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Application status updated successfully.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string", + "example": "Application status updated successfully." + }, + "data": { + "type": "object", + "properties": { + "application": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "example": 10 + }, + "worker_id": { + "type": "integer", + "example": 136 + }, + "job_id": { + "type": "integer", + "example": 1 + }, + "status": { + "type": "string", + "example": "shortlisted" + }, + "created_at": { + "type": "string", + "format": "date-time" + }, + "updated_at": { + "type": "string", + "format": "date-time" + } + } + } + } + } + } + } + } + } + }, + "401": { + "description": "Unauthenticated or invalid Bearer token." + }, + "403": { + "description": "Contact limit reached under the current subscription plan.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": false + }, + "message": { + "type": "string", + "example": "You have reached your contacted workers limit of 10 under your active plan. Please upgrade or renew your subscription to contact more workers." + } + } + } + } + } + }, + "404": { + "description": "Job application not found or unauthorized access." + }, + "422": { + "description": "Validation error (e.g. invalid status)." + } + } + } + }, "/workers/forgot-password": { "post": { "tags": [ @@ -6557,6 +7422,164 @@ } } } + }, + "/employers/applications/{id}/status": { + "post": { + "tags": [ + "Employer/Jobs" + ], + "summary": "Update Job Application Status", + "description": "Allows employers to update the status of a job application (e.g. shortlist, interview, hire, reject) with optional internal notes. Enforces plan-based contact limits and automatically adds to shortlist/Saved Workers.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The Job Application ID", + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "status" + ], + "properties": { + "status": { + "type": "string", + "enum": [ + "applied", + "shortlisted", + "contacted", + "interview scheduled", + "interview_scheduled", + "selected", + "rejected", + "hired" + ], + "description": "New hiring stage status" + }, + "notes": { + "type": "string", + "description": "Optional internal notes about the applicant/interview" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Status updated successfully.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { "type": "boolean", "example": true }, + "message": { "type": "string", "example": "Application status updated successfully." } + } + } + } + } + }, + "403": { + "description": "Plan limits reached or unauthorized." + } + } + } + }, + "/workers/applied-jobs/{id}": { + "get": { + "tags": [ + "Worker/Jobs" + ], + "summary": "Get Applied Job Details", + "description": "Retrieves the detail, current status, and complete status history audit log of a worker's job application.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The Job Application ID", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Application details retrieved.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { "type": "boolean", "example": true }, + "data": { + "type": "object", + "properties": { + "application": { + "type": "object", + "properties": { + "id": { "type": "integer", "example": 12 }, + "status": { "type": "string", "example": "shortlisted" }, + "status_history": { + "type": "array", + "items": { + "type": "object", + "properties": { + "status": { "type": "string" }, + "timestamp": { "type": "string" }, + "notes": { "type": "string", "nullable": true } + } + } + } + } + } + } + } + } + } + } + } + } + } + } + }, + "/workers/applied-jobs/{id}/withdraw": { + "post": { + "tags": [ + "Worker/Jobs" + ], + "summary": "Withdraw Job Application", + "description": "Allows a worker to withdraw their application. Only permitted if the application is still in 'applied' status.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "The Job Application ID", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Application successfully withdrawn." + }, + "400": { + "description": "Cannot withdraw (status is not 'applied')." + } + } + } } }, "components": { diff --git a/resources/js/Layouts/EmployerLayout.jsx b/resources/js/Layouts/EmployerLayout.jsx index 242e604..d5cf296 100644 --- a/resources/js/Layouts/EmployerLayout.jsx +++ b/resources/js/Layouts/EmployerLayout.jsx @@ -58,6 +58,7 @@ export default function EmployerLayout({ children, title, fullPage = false }) { email: '', subscription_status: 'active', subscription_expires_at: '', + enable_job_apply: false, }; const isSubActive = true; @@ -67,7 +68,7 @@ export default function EmployerLayout({ children, title, fullPage = false }) { { name: 'Find Workers', translationKey: 'find_workers', href: '/employer/workers', icon: Search }, { name: 'Shortlist', translationKey: 'shortlist', href: '/employer/shortlist', icon: Bookmark }, { name: 'Hired Workers', translationKey: 'candidates', href: '/employer/candidates', icon: UserCheck }, - { name: 'My Jobs', translationKey: 'my_jobs', href: '/employer/jobs', icon: Briefcase }, + ...(user.enable_job_apply ? [{ name: 'My Jobs', translationKey: 'my_jobs', href: '/employer/jobs', icon: Briefcase }] : []), { name: 'Messages', translationKey: 'messages', href: '/employer/messages', icon: MessageSquare, badge: unread_messages_count }, { name: 'Charity Events', translationKey: 'charity_events', href: '/employer/events', icon: Heart }, { name: 'Subscription', translationKey: 'subscription', href: '/employer/subscription', icon: CreditCard }, diff --git a/resources/js/Pages/Admin/Subscriptions/Index.jsx b/resources/js/Pages/Admin/Subscriptions/Index.jsx index cc4b4cf..96ebf7a 100644 --- a/resources/js/Pages/Admin/Subscriptions/Index.jsx +++ b/resources/js/Pages/Admin/Subscriptions/Index.jsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { Head, router } from '@inertiajs/react'; import AdminLayout from '@/Layouts/AdminLayout'; import { @@ -34,12 +34,26 @@ import { DialogTrigger, } from "@/components/ui/dialog"; -export default function SubscriptionsIndex({ plans: initialPlans }) { - const [plans, setPlans] = useState(initialPlans || [ - { id: 'basic', name: 'Basic Search', price: 99, duration: 'Monthly', features: ['Browse 500+ workers', '10 Shortlists'], status: 'Active', maxContactPeople: 10, enableJobApply: false }, - { id: 'premium', name: 'Premium Pass', price: 199, duration: 'Monthly', features: ['Unlimited shortlisting', 'Direct messaging'], status: 'Active', maxContactPeople: 50, enableJobApply: true }, - { id: 'vip', name: 'VIP Concierge', price: 499, duration: 'Monthly', features: ['Dedicated Manager', '30-day replacement'], status: 'Active', maxContactPeople: 100, enableJobApply: true }, - ]); +export default function SubscriptionsIndex({ plans: initialPlans, errors = {} }) { + const mapIncomingPlans = (rawPlans) => { + if (!rawPlans) return []; + return rawPlans.map(p => ({ + ...p, + price: Number(p.price), + maxContactPeople: p.max_contact_people !== undefined ? p.max_contact_people : p.maxContactPeople, + unlimitedContacts: p.unlimited_contacts !== undefined ? !!p.unlimited_contacts : !!p.unlimitedContacts, + enableJobApply: p.enable_job_apply !== undefined ? !!p.enable_job_apply : !!p.enableJobApply, + usageCount: p.usage_count !== undefined ? Number(p.usage_count) : 0 + })); + }; + + const [plans, setPlans] = useState(() => mapIncomingPlans(initialPlans || [])); + + useEffect(() => { + if (initialPlans) { + setPlans(mapIncomingPlans(initialPlans)); + } + }, [initialPlans]); const [isDialogOpen, setIsDialogOpen] = useState(false); const [editingPlan, setEditingPlan] = useState(null); @@ -51,7 +65,9 @@ export default function SubscriptionsIndex({ plans: initialPlans }) { const [duration, setDuration] = useState('Monthly'); const [features, setFeatures] = useState(''); const [maxContactPeople, setMaxContactPeople] = useState(''); + const [unlimitedContacts, setUnlimitedContacts] = useState(false); const [enableJobApply, setEnableJobApply] = useState(false); + const [status, setStatus] = useState('Active'); const handleCreateClick = () => { setEditingPlan(null); @@ -60,7 +76,9 @@ export default function SubscriptionsIndex({ plans: initialPlans }) { setDuration('Monthly'); setFeatures(''); setMaxContactPeople(''); + setUnlimitedContacts(false); setEnableJobApply(false); + setStatus('Active'); setIsDialogOpen(true); }; @@ -70,14 +88,20 @@ export default function SubscriptionsIndex({ plans: initialPlans }) { setPrice(plan.price); setDuration(plan.duration); setFeatures(plan.features.join('\n')); - setMaxContactPeople(plan.maxContactPeople !== undefined ? plan.maxContactPeople : ''); + setMaxContactPeople(plan.maxContactPeople !== null && plan.maxContactPeople !== undefined ? plan.maxContactPeople : ''); + setUnlimitedContacts(!!plan.unlimitedContacts); setEnableJobApply(!!plan.enableJobApply); + setStatus(plan.status || 'Active'); setIsDialogOpen(true); }; const handleDelete = (id) => { if (confirm('Are you sure you want to delete this plan?')) { - setPlans(plans.filter(p => p.id !== id)); + router.delete(`/admin/subscriptions/${id}`, { + onSuccess: () => { + // Props are re-loaded automatically by Inertia, updating state via useEffect + } + }); } }; @@ -93,30 +117,34 @@ export default function SubscriptionsIndex({ plans: initialPlans }) { .map(f => f.trim()) .filter(Boolean); + const payload = { + name, + price: Number(price), + duration, + features: featuresArray, + max_contact_people: unlimitedContacts ? null : (maxContactPeople !== '' ? Number(maxContactPeople) : null), + unlimited_contacts: !!unlimitedContacts, + enable_job_apply: !!enableJobApply, + status + }; + if (editingPlan) { - setPlans(plans.map(p => p.id === editingPlan.id ? { - ...p, - name, - price: Number(price), - duration, - features: featuresArray, - maxContactPeople: maxContactPeople !== '' ? Number(maxContactPeople) : '', - enableJobApply - } : p)); + router.post(`/admin/subscriptions/${editingPlan.id}/update`, payload, { + onSuccess: () => { + setIsDialogOpen(false); + } + }); } else { const newId = name.toLowerCase().replace(/\s+/g, '-'); - setPlans([...plans, { + router.post('/admin/subscriptions', { id: newId || `plan-${Date.now()}`, - name, - price: Number(price), - duration, - features: featuresArray, - status: 'Active', - maxContactPeople: maxContactPeople !== '' ? Number(maxContactPeople) : '', - enableJobApply - }]); + ...payload + }, { + onSuccess: () => { + setIsDialogOpen(false); + } + }); } - setIsDialogOpen(false); }; const filteredPlans = plans.filter(plan => @@ -130,6 +158,13 @@ export default function SubscriptionsIndex({ plans: initialPlans }) {
+ {errors.status && ( +
+ + {errors.status} +
+ )} + {/* Header Actions */}
@@ -192,7 +227,7 @@ export default function SubscriptionsIndex({ plans: initialPlans }) { {plan.price} {plan.duration} - {plan.maxContactPeople || 'Unlimited'} + {plan.unlimitedContacts ? 'Unlimited' : (plan.maxContactPeople || 'Unlimited')} - - {plan.status} - +
+ + {plan.status || 'Active'} + + {plan.usageCount > 0 && ( + + {plan.usageCount} {plan.usageCount === 1 ? 'user' : 'users'} + + )} +
@@ -242,7 +284,7 @@ export default function SubscriptionsIndex({ plans: initialPlans }) { {/* Plan Modal */} - + {editingPlan ? 'Edit Subscription Plan' : 'Create New Plan'} @@ -291,17 +333,82 @@ export default function SubscriptionsIndex({ plans: initialPlans }) {
- +
+ + +
setMaxContactPeople(e.target.value)} - className="w-full px-4 py-2.5 bg-slate-50 border border-slate-100 rounded-xl text-sm font-bold focus:ring-4 focus:ring-teal-500/10 outline-none" - placeholder="e.g. 10 (blank for unlimited)" + className={`w-full px-4 py-2.5 border rounded-xl text-sm font-bold focus:ring-4 focus:ring-teal-500/10 outline-none transition-all ${ + unlimitedContacts + ? 'bg-slate-100 border-slate-200 text-slate-400 cursor-not-allowed' + : 'bg-slate-50 border-slate-100 text-slate-800' + }`} + placeholder={unlimitedContacts ? 'Unlimited' : 'e.g. 10'} />
+
+
+
+ + + {status} + +
+ + Active plans are visible to employers during registration. + + {errors.status && ( + {errors.status} + )} +
+ +
+ + {editingPlan && editingPlan.usageCount > 0 && ( +
+ + + Warning: {editingPlan.usageCount} {editingPlan.usageCount === 1 ? 'person is' : 'people are'} currently using this plan. Disabling this plan is blocked. + +
+ )} +
{ + setModalData(prev => ({ ...prev, [field]: value })); + if (modalError) setModalError(null); + }; + + const handleConfirmSubmit = async () => { + if (!modalData.name.trim()) return setModalError('Full name is required.'); + if (!modalData.emirates_id_number.trim()) return setModalError('Emirates ID number is required.'); + if (!modalData.expiry_date.trim()) return setModalError('Expiry date is required.'); + if (!modalData.date_of_birth.trim()) return setModalError('Date of birth is required.'); + + setModalSaving(true); + setModalError(null); + + try { + await axios.post('/employer/confirm-emirates-id', modalData); + toast.success('Emirates ID details confirmed!'); + setShowModal(false); + router.visit('/employer/register-payment'); + } catch (err) { + if (err.response && err.response.data && err.response.data.errors) { + const errors = err.response.data.errors; + const firstError = Object.values(errors).flat()[0]; + setModalError(firstError || 'Validation failed.'); + } else { + setModalError(err.response?.data?.error || 'Failed to save confirmed data.'); + } + } finally { + setModalSaving(false); + } + }; + const handleFrontFileChange = (e) => { const selectedFile = e.target.files[0]; if (selectedFile) { @@ -62,13 +112,28 @@ export default function UploadEmiratesId({ email }) { formData.append('emirates_id_back', backFile); try { - await axios.post('/employer/upload-emirates-id', formData, { + const res = await axios.post('/employer/upload-emirates-id', formData, { headers: { 'Content-Type': 'multipart/form-data' } }); - toast.success('Emirates ID scanned successfully. Data extracted and document deleted.'); - router.visit('/employer/register-payment'); + toast.success('Emirates ID scanned successfully!'); + + const extracted = res.data?.data || {}; + setModalData({ + name: extracted.name || '', + emirates_id_number: extracted.emirates_id_number || '', + expiry_date: extracted.expiry_date || '', + date_of_birth: extracted.date_of_birth || '', + nationality: extracted.nationality || '', + gender: extracted.gender || '', + card_number: extracted.card_number || '', + occupation: extracted.occupation || '', + employer: extracted.employer || '', + issue_place: extracted.issue_place || '', + issue_date: extracted.issue_date || '', + }); + setShowModal(true); } catch (err) { if (err.response && err.response.data && err.response.data.errors) { const errors = err.response.data.errors; @@ -77,7 +142,9 @@ export default function UploadEmiratesId({ email }) { setError(errorMsg); toast.error(errorMsg); } else { - toast.error('Scan extraction failed. Please try again.'); + const errorMsg = err.response?.data?.error || 'Scan extraction failed. Please try again.'; + setError(errorMsg); + toast.error(errorMsg); } } finally { setLoading(false); @@ -311,6 +378,184 @@ export default function UploadEmiratesId({ email }) {
+ + {/* Confirmation and Edit Modal */} + {showModal && ( +
+
+ {/* Header */} +
+
+

Confirm Extracted EID Data

+

Please verify and correct the scanned Emirates ID details before proceeding.

+
+
+ + {/* Form Fields */} +
+
+ {/* Full Name */} +
+ + handleModalChange('name', e.target.value)} + className="w-full px-3 py-2 rounded-xl border border-slate-300 text-sm focus:outline-none focus:ring-2 focus:ring-[#185FA5]/20 focus:border-[#185FA5]" + /> +
+ + {/* Emirates ID Number */} +
+ + handleModalChange('emirates_id_number', e.target.value)} + placeholder="784-XXXX-XXXXXXX-X" + className="w-full px-3 py-2 rounded-xl border border-slate-300 text-sm focus:outline-none focus:ring-2 focus:ring-[#185FA5]/20 focus:border-[#185FA5]" + /> +
+ + {/* Expiry Date */} +
+ + handleModalChange('expiry_date', e.target.value)} + className="w-full px-3 py-2 rounded-xl border border-slate-300 text-sm focus:outline-none focus:ring-2 focus:ring-[#185FA5]/20 focus:border-[#185FA5]" + /> +
+ + {/* Date of Birth */} +
+ + handleModalChange('date_of_birth', e.target.value)} + className="w-full px-3 py-2 rounded-xl border border-slate-300 text-sm focus:outline-none focus:ring-2 focus:ring-[#185FA5]/20 focus:border-[#185FA5]" + /> +
+ + {/* Nationality */} +
+ + handleModalChange('nationality', e.target.value)} + className="w-full px-3 py-2 rounded-xl border border-slate-300 text-sm focus:outline-none focus:ring-2 focus:ring-[#185FA5]/20 focus:border-[#185FA5]" + /> +
+ + {/* Gender */} +
+ + +
+ + {/* Card Number */} +
+ + handleModalChange('card_number', e.target.value)} + className="w-full px-3 py-2 rounded-xl border border-slate-300 text-sm focus:outline-none focus:ring-2 focus:ring-[#185FA5]/20 focus:border-[#185FA5]" + /> +
+ + {/* Occupation */} +
+ + handleModalChange('occupation', e.target.value)} + className="w-full px-3 py-2 rounded-xl border border-slate-300 text-sm focus:outline-none focus:ring-2 focus:ring-[#185FA5]/20 focus:border-[#185FA5]" + /> +
+ + {/* Employer */} +
+ + handleModalChange('employer', e.target.value)} + className="w-full px-3 py-2 rounded-xl border border-slate-300 text-sm focus:outline-none focus:ring-2 focus:ring-[#185FA5]/20 focus:border-[#185FA5]" + /> +
+ + {/* Issue Place */} +
+ + handleModalChange('issue_place', e.target.value)} + className="w-full px-3 py-2 rounded-xl border border-slate-300 text-sm focus:outline-none focus:ring-2 focus:ring-[#185FA5]/20 focus:border-[#185FA5]" + /> +
+ + {/* Issue Date */} +
+ + handleModalChange('issue_date', e.target.value)} + className="w-full px-3 py-2 rounded-xl border border-slate-300 text-sm focus:outline-none focus:ring-2 focus:ring-[#185FA5]/20 focus:border-[#185FA5]" + /> +
+
+ + {modalError && ( +

+ + {modalError} +

+ )} +
+ + {/* Actions */} +
+ + +
+
+
+ )} ); } diff --git a/resources/js/Pages/Employer/Dashboard.jsx b/resources/js/Pages/Employer/Dashboard.jsx index 96506db..cdb3ea8 100644 --- a/resources/js/Pages/Employer/Dashboard.jsx +++ b/resources/js/Pages/Employer/Dashboard.jsx @@ -226,7 +226,7 @@ export default function Dashboard({
{t('dubai_insights', 'Dubai General Market Insights')}
-
1,284
+
{stats.total_hired_all || 0}
{t('hired_dubai', 'Hired using app in Dubai')}
@@ -234,8 +234,8 @@ export default function Dashboard({
{t('top_skills', 'Top Skills Hired')}
-
1.4%
-
{t('turnover_rate', 'Turnover rate')}
+
{stats.total_active_workers || 0}
+
{t('active_candidates', 'Active Candidates')}
@@ -245,7 +245,7 @@ export default function Dashboard({
{t('sponsor_activity', 'Your Sponsor Activity Stats')}
-
14
+
{stats.contacted_workers_count || 0}
{t('workers_contacted', 'Workers Contacted')}
diff --git a/resources/js/Pages/Employer/Jobs/Applicants.jsx b/resources/js/Pages/Employer/Jobs/Applicants.jsx index 6114f22..86c3814 100644 --- a/resources/js/Pages/Employer/Jobs/Applicants.jsx +++ b/resources/js/Pages/Employer/Jobs/Applicants.jsx @@ -1,5 +1,5 @@ import React, { useState } from 'react'; -import { Head, Link } from '@inertiajs/react'; +import { Head, Link, router } from '@inertiajs/react'; import EmployerLayout from '@/Layouts/EmployerLayout'; import { ArrowLeft, @@ -13,24 +13,53 @@ import { Search, Filter, ShieldCheck, - Star + Star, + X, + FileText } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; +import { toast } from 'sonner'; export default function Applicants({ job, applicants: initialApplicants }) { const [searchTerm, setSearchTerm] = useState(''); - const [applicants, setApplicants] = useState(initialApplicants || [ - { id: 101, name: 'Anjali Sharma', nationality: 'Indian', salary: 2500, experience: '5 Years', status: 'Reviewing', match_score: 95 }, - { id: 102, name: 'Siti Rahma', nationality: 'Indonesian', salary: 1800, experience: '3 Years', status: 'Hired', match_score: 88 }, - { id: 103, name: 'Maricel Cruz', nationality: 'Filipino', salary: 2200, experience: '7 Years', status: 'Rejected', match_score: 72 }, - { id: 104, name: 'Bebeth Santos', nationality: 'Filipino', salary: 3000, experience: '10 Years', status: 'Reviewing', match_score: 98 }, - ]); + const [applicants, setApplicants] = useState(initialApplicants || []); + + // Modal states + const [selectedApp, setSelectedApp] = useState(null); + const [newStatus, setNewStatus] = useState(''); + const [notes, setNotes] = useState(''); + const [showModal, setShowModal] = useState(false); const filteredApplicants = applicants.filter(a => a.name.toLowerCase().includes(searchTerm.toLowerCase()) || a.nationality.toLowerCase().includes(searchTerm.toLowerCase()) ); + const openStatusModal = (app, statusPreset = '') => { + setSelectedApp(app); + setNewStatus(statusPreset || app.status || 'Applied'); + setNotes(app.notes || ''); + setShowModal(true); + }; + + const handleSaveStatus = () => { + if (!selectedApp) return; + + router.post(`/employer/candidates/${selectedApp.application_id}/status`, { + status: newStatus, + notes: notes + }, { + onSuccess: () => { + toast.success('Applicant status updated successfully.'); + setShowModal(false); + setSelectedApp(null); + }, + onError: (errors) => { + toast.error('Failed to update status: ' . (errors.error || errors.status || 'Error occurred')); + } + }); + }; + return ( @@ -50,14 +79,16 @@ export default function Applicants({ job, applicants: initialApplicants }) { Applicants for {job?.title || 'Senior Mason Project'}
- {job?.salary || '2800'} AED + {job?.salary || '2800'} AED
Hiring Progress
-
{applicants.filter(a => a.status === 'Hired').length} / 5 Positions
+
+ {applicants.filter(a => a.status?.toLowerCase() === 'hired').length} / {job?.workers_needed || 5} Positions +
@@ -83,7 +114,7 @@ export default function Applicants({ job, applicants: initialApplicants }) {
- {/* Applicants List */} + {/* Applicants Grid */}
{filteredApplicants.length > 0 ? ( filteredApplicants.map((applicant) => ( @@ -96,7 +127,12 @@ export default function Applicants({ job, applicants: initialApplicants }) { {applicant.name.charAt(0)}
-

{applicant.name}

+

+ {applicant.name} + {applicant.is_saved && ( + + )} +

{applicant.nationality} @@ -123,13 +159,21 @@ export default function Applicants({ job, applicants: initialApplicants }) {
+ {/* Internal Notes Preview */} + {applicant.notes && ( +
+ + {applicant.notes} +
+ )} + {/* Current Status Badge */}
- + }`} onClick={() => openStatusModal(applicant)}> {applicant.status}
@@ -142,14 +186,25 @@ export default function Applicants({ job, applicants: initialApplicants }) { {/* Actions Bar */}
- - -
@@ -157,12 +212,100 @@ export default function Applicants({ job, applicants: initialApplicants }) { )) ) : (
- +

No applicants found for this job yet.

)}
+ + {/* Status Update Modal */} + {showModal && ( +
+
+ {/* Modal Header */} +
+
+

Update Application Status

+

{selectedApp?.name}

+
+ +
+ + {/* Modal Body */} +
+ {/* Status Selector */} +
+ + +
+ + {/* Notes Textarea */} +
+ +