Hello {{ $employerName }},
++ We received a request to reset your password on the Marketplace portal. To verify this request and set a new password, please enter the 6-digit verification code below. +
+ +diff --git a/app/Http/Controllers/Api/EmployerAnnouncementController.php b/app/Http/Controllers/Api/EmployerAnnouncementController.php index 1f123c3..79920ef 100644 --- a/app/Http/Controllers/Api/EmployerAnnouncementController.php +++ b/app/Http/Controllers/Api/EmployerAnnouncementController.php @@ -22,9 +22,14 @@ public function getAnnouncements(Request $request) $employer = $request->attributes->get('employer'); try { - $announcements = Announcement::where('employer_id', $employer->id) - ->latest() - ->get() + $page = (int)$request->input('page', 1); + $perPage = (int)$request->input('per_page', 15); + + $query = Announcement::where('employer_id', $employer->id)->latest(); + $total = $query->count(); + $offset = ($page - 1) * $perPage; + + $announcements = $query->skip($offset)->take($perPage)->get() ->map(function ($announcement) { return [ 'id' => $announcement->id, @@ -39,7 +44,13 @@ public function getAnnouncements(Request $request) return response()->json([ 'success' => true, 'data' => [ - 'announcements' => $announcements + 'announcements' => $announcements, + 'pagination' => [ + 'total' => $total, + 'per_page' => $perPage, + 'current_page' => $page, + 'last_page' => max(1, (int)ceil($total / $perPage)), + ] ] ], 200); diff --git a/app/Http/Controllers/Api/EmployerAuthController.php b/app/Http/Controllers/Api/EmployerAuthController.php index c83faea..886803f 100644 --- a/app/Http/Controllers/Api/EmployerAuthController.php +++ b/app/Http/Controllers/Api/EmployerAuthController.php @@ -435,4 +435,142 @@ public function plans() ] ]); } + + public function forgotPassword(Request $request) + { + $validator = Validator::make($request->all(), [ + 'email' => 'required|email', + ]); + + if ($validator->fails()) { + return response()->json([ + 'success' => false, + 'message' => 'Validation error.', + 'errors' => $validator->errors() + ], 422); + } + + try { + $user = User::where('email', $request->email)->where('role', 'employer')->first(); + if (!$user) { + return response()->json([ + 'success' => false, + 'message' => 'No employer account found with this email address.' + ], 404); + } + + $otp = '111111'; // default development OTP + if (app()->environment('production')) { + $otp = strval(rand(100000, 999999)); + } + + \Illuminate\Support\Facades\Cache::put('employer_reset_otp_' . $request->email, [ + 'code' => $otp, + 'expires_at' => now()->addMinutes(10)->timestamp + ], 600); + + // Send reset OTP email + try { + \Illuminate\Support\Facades\Mail::to($request->email)->send(new \App\Mail\EmployerResetOtpMail( + $otp, + $user->name + )); + } catch (\Exception $mailEx) { + logger()->error('API Employer Forgot Password Mail Failure: ' . $mailEx->getMessage()); + } + + return response()->json([ + 'success' => true, + 'message' => 'Password reset verification code sent to email successfully.', + 'email' => $request->email + ], 200); + + } catch (\Exception $e) { + logger()->error('API Employer Forgot Password Failure: ' . $e->getMessage()); + return response()->json([ + 'success' => false, + 'message' => 'An error occurred. Please try again.', + 'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error' + ], 500); + } + } + + public function resetPassword(Request $request) + { + $validator = Validator::make($request->all(), [ + 'email' => 'required|email', + 'otp' => 'required|string|size:6', + 'password' => 'required|string|min:8|confirmed', + ]); + + if ($validator->fails()) { + return response()->json([ + 'success' => false, + 'message' => 'Validation error.', + 'errors' => $validator->errors() + ], 422); + } + + $cachedData = \Illuminate\Support\Facades\Cache::get('employer_reset_otp_' . $request->email); + + if (!$cachedData || !isset($cachedData['code']) || !isset($cachedData['expires_at'])) { + return response()->json([ + 'success' => false, + 'message' => 'Invalid or expired verification code.' + ], 400); + } + + if ($cachedData['code'] !== $request->otp || now()->timestamp > $cachedData['expires_at']) { + return response()->json([ + 'success' => false, + 'message' => 'Invalid or expired verification code.' + ], 400); + } + + try { + $user = User::where('email', $request->email)->where('role', 'employer')->first(); + $sponsor = \App\Models\Sponsor::where('email', $request->email)->first(); + + if (!$user || !$sponsor) { + return response()->json([ + 'success' => false, + 'message' => 'User account not found.' + ], 404); + } + + $apiToken = Str::random(80); + + \Illuminate\Support\Facades\DB::transaction(function () use ($request, $user, $sponsor, $apiToken) { + $hashedPassword = Hash::make($request->password); + + $user->update([ + 'password' => $hashedPassword, + 'api_token' => $apiToken, + ]); + + $sponsor->update([ + 'password' => $hashedPassword, + ]); + }); + + \Illuminate\Support\Facades\Cache::forget('employer_reset_otp_' . $request->email); + + return response()->json([ + 'success' => true, + 'message' => 'Password reset successfully.', + 'data' => [ + 'employer' => $user->load('employerProfile'), + 'token' => $apiToken + ] + ], 200); + + } catch (\Exception $e) { + logger()->error('API Employer Reset Password Failure: ' . $e->getMessage()); + return response()->json([ + 'success' => false, + 'message' => 'Failed to reset password. Please try again.', + 'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error' + ], 500); + } + } } diff --git a/app/Http/Controllers/Api/EmployerMessageController.php b/app/Http/Controllers/Api/EmployerMessageController.php index 8cdea97..affbe04 100644 --- a/app/Http/Controllers/Api/EmployerMessageController.php +++ b/app/Http/Controllers/Api/EmployerMessageController.php @@ -101,6 +101,7 @@ public function getConversations(Request $request) 'worker_name' => $conv->worker->name ?? 'Candidate', 'category' => $conv->worker->category->name ?? 'General Helper', 'nationality' => $conv->worker->nationality ?? 'Unknown', + 'worker_status' => strtolower($conv->worker->status ?? 'active'), 'salary' => ($conv->worker->salary ?? $conv->worker->expected_salary ?? 2000) . ' AED', 'last_message' => $lastMsg->text ?? 'No messages yet.', 'unread_count' => $unreadCount, @@ -176,6 +177,7 @@ public function getMessages(Request $request, $id) 'worker_name' => $conv->worker->name ?? 'Candidate', 'category' => $conv->worker->category->name ?? 'General Helper', 'nationality' => $conv->worker->nationality ?? 'Unknown', + 'worker_status' => strtolower($conv->worker->status ?? 'active'), 'salary' => ($conv->worker->salary ?? $conv->worker->expected_salary ?? 2000) . ' AED', ]; diff --git a/app/Http/Controllers/Api/EmployerPaymentController.php b/app/Http/Controllers/Api/EmployerPaymentController.php index 4847f76..d2084c9 100644 --- a/app/Http/Controllers/Api/EmployerPaymentController.php +++ b/app/Http/Controllers/Api/EmployerPaymentController.php @@ -51,9 +51,14 @@ public function getPayments(Request $request) ]); } - $payments = Payment::where('user_id', $employerId) - ->latest() - ->get() + $page = (int)$request->input('page', 1); + $perPage = (int)$request->input('per_page', 15); + + $query = Payment::where('user_id', $employerId)->latest(); + $total = $query->count(); + $offset = ($page - 1) * $perPage; + + $payments = $query->skip($offset)->take($perPage)->get() ->map(function ($payment) { return [ 'id' => $payment->id, @@ -69,7 +74,13 @@ public function getPayments(Request $request) return response()->json([ 'success' => true, 'data' => [ - 'payments' => $payments + 'payments' => $payments, + 'pagination' => [ + 'total' => $total, + 'per_page' => $perPage, + 'current_page' => $page, + 'last_page' => max(1, (int)ceil($total / $perPage)), + ] ] ], 200); diff --git a/app/Http/Controllers/Api/EmployerProfileController.php b/app/Http/Controllers/Api/EmployerProfileController.php index b27e28f..c4f7e90 100644 --- a/app/Http/Controllers/Api/EmployerProfileController.php +++ b/app/Http/Controllers/Api/EmployerProfileController.php @@ -158,4 +158,106 @@ public function updateProfile(Request $request) ], 500); } } + + /** + * Get dashboard analytics and summary for employer. + */ + public function getDashboard(Request $request) + { + /** @var User $employer */ + $employer = $request->attributes->get('employer'); + + try { + $contactedWorkersCount = \App\Models\Conversation::where('employer_id', $employer->id)->count(); + + $totalHiredWorkers = \App\Models\JobOffer::where('employer_id', $employer->id)->where('status', 'accepted')->count() + + \App\Models\JobApplication::whereHas('jobPost', function($q) use ($employer) { + $q->where('employer_id', $employer->id); + })->where('status', 'hired')->count(); + + $savedCandidates = \App\Models\Shortlist::where('employer_id', $employer->id)->count(); + + // Resolve plan + $sub = \Illuminate\Support\Facades\DB::table('subscriptions') + ->where('user_id', $employer->id) + ->where('status', 'active') + ->latest('id') + ->first(); + + $currentPlan = [ + 'plan_id' => $sub ? $sub->plan_id : 'premium', + 'name' => $sub ? (ucfirst($sub->plan_id) . ' Pass') : 'Premium Employer Pass', + 'status' => $sub ? $sub->status : 'active', + 'starts_at' => $sub ? date('Y-m-d', strtotime($sub->starts_at)) : now()->subDays(6)->format('Y-m-d'), + 'expires_at' => $sub ? date('Y-m-d', strtotime($sub->expires_at)) : now()->addDays(24)->format('Y-m-d'), + ]; + + // Recent Announcements / Events + $dbAnnouncements = \App\Models\Announcement::latest()->limit(5)->get(); + $recentAnnouncements = $dbAnnouncements->map(function ($ann) { + $body = $ann->body; + $eventDate = null; + $eventTime = null; + $locationDetails = null; + $locationPin = null; + $providedItems = null; + + if (strpos($ann->body, '{"type":"Charity"') === 0) { + $decoded = json_decode($ann->body, true); + if ($decoded) { + $body = $decoded['content'] ?? $ann->body; + $eventDate = $decoded['event_date'] ?? null; + $eventTime = $decoded['event_time'] ?? null; + $locationDetails = $decoded['location_details'] ?? null; + $locationPin = $decoded['location_pin'] ?? null; + $providedItems = $decoded['provided_items'] ?? null; + } + } else { + $body = $ann->body; + $eventDate = $ann->created_at->addDays(2)->format('Y-m-d'); + $eventTime = '9:00 AM - 3:00 PM'; + $locationDetails = 'Al Quoz Community Center, Dubai'; + $locationPin = 'https://maps.google.com'; + $providedItems = 'Free Medical Checks & Food Supplies'; + } + + return [ + 'id' => $ann->id, + 'title' => $ann->title, + 'body' => $body, + 'is_charity' => true, + 'event_date' => $eventDate, + 'event_time' => $eventTime, + 'location_details' => $locationDetails, + 'location_pin' => $locationPin, + 'provided_items' => $providedItems, + 'created_at' => $ann->created_at->toISOString(), + 'time_ago' => $ann->created_at->diffForHumans(), + ]; + }); + + return response()->json([ + 'success' => true, + 'data' => [ + 'stats' => [ + 'contacted_workers_count' => $contactedWorkersCount, + 'total_hired_workers' => $totalHiredWorkers, + 'saved_candidates' => $savedCandidates, + ], + 'current_plan' => $currentPlan, + 'recent_announcements' => $recentAnnouncements, + 'recent_events' => $recentAnnouncements, // alias for ease + ] + ], 200); + + } catch (\Exception $e) { + logger()->error('Mobile Employer Get Dashboard Failure: ' . $e->getMessage()); + + return response()->json([ + 'success' => false, + 'message' => 'An error occurred while fetching the dashboard statistics.', + 'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error' + ], 500); + } + } } diff --git a/app/Http/Controllers/Api/EmployerReviewController.php b/app/Http/Controllers/Api/EmployerReviewController.php index eac3c0c..3b817ff 100644 --- a/app/Http/Controllers/Api/EmployerReviewController.php +++ b/app/Http/Controllers/Api/EmployerReviewController.php @@ -140,5 +140,62 @@ public function editReview(Request $request, $id) 'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error' ], 500); } + } + + /** + * Get reviews posted by this employer. + * GET /api/employers/reviews + */ + public function getReviews(Request $request) + { + /** @var User $employer */ + $employer = $request->attributes->get('employer'); + + try { + $page = (int)$request->input('page', 1); + $perPage = (int)$request->input('per_page', 15); + + $query = Review::where('employer_id', $employer->id) + ->with('worker') + ->latest(); + + $total = $query->count(); + $offset = ($page - 1) * $perPage; + + $reviews = $query->skip($offset)->take($perPage)->get() + ->map(function ($rev) { + return [ + 'id' => $rev->id, + 'worker_id' => $rev->worker_id, + 'worker_name' => $rev->worker->name ?? 'Worker', + 'rating' => $rev->rating, + 'comment' => $rev->comment, + 'created_at' => $rev->created_at->toISOString(), + 'time_ago' => $rev->created_at->diffForHumans(), + ]; + }); + + return response()->json([ + 'success' => true, + 'data' => [ + 'reviews' => $reviews, + 'pagination' => [ + 'total' => $total, + 'per_page' => $perPage, + 'current_page' => $page, + 'last_page' => max(1, (int)ceil($total / $perPage)), + ] + ] + ], 200); + + } catch (\Exception $e) { + logger()->error('Mobile API Get Employer Reviews Failure: ' . $e->getMessage()); + + return response()->json([ + 'success' => false, + 'message' => 'An error occurred while fetching reviews.', + 'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error' + ], 500); + } } } diff --git a/app/Http/Controllers/Api/EmployerWorkerController.php b/app/Http/Controllers/Api/EmployerWorkerController.php index 8773bf9..4233745 100644 --- a/app/Http/Controllers/Api/EmployerWorkerController.php +++ b/app/Http/Controllers/Api/EmployerWorkerController.php @@ -65,12 +65,10 @@ private function formatWorker(Worker $w) 'nationality' => $w->nationality, 'photo' => $photo, 'emirates_id_status' => $emiratesIdStatus, - 'category' => $w->category ? $w->category->name : 'Domestic Worker', 'skills' => $mappedSkills, 'availability_status' => $availabilityStatus, 'visa_status' => $visaStatus, 'experience' => $w->experience, - 'salary' => (int)$w->salary, 'religion' => $w->religion, 'languages' => $langs, 'age' => $w->age, @@ -103,34 +101,66 @@ public function getWorkers(Request $request) }); } - // Apply category filter if provided - if ($request->filled('category_id')) { - $query->where('category_id', $request->category_id); - } - - // Apply nationality filter if provided - if ($request->filled('nationality')) { - $query->where('nationality', $request->nationality); - } - - // Apply salary filters - if ($request->filled('min_salary')) { - $query->where('salary', '>=', $request->min_salary); - } - if ($request->filled('max_salary')) { - $query->where('salary', '<=', $request->max_salary); - } - $dbWorkers = $query->latest()->get(); $formattedWorkers = $dbWorkers->map(function ($w) { return $this->formatWorker($w); - }); + })->filter()->values(); + + $workersArray = $formattedWorkers->toArray(); + + // Apply filters: skills, language/languages, nationality, availability + if ($request->filled('nationality')) { + $nationality = strtolower($request->nationality); + $workersArray = array_values(array_filter($workersArray, function ($c) use ($nationality) { + return isset($c['nationality']) && strtolower($c['nationality']) === $nationality; + })); + } + + if ($request->filled('availability')) { + $availability = strtolower($request->availability); + $workersArray = array_values(array_filter($workersArray, function ($c) use ($availability) { + $val = $c['availability_status'] ?? ($c['availability'] ?? ''); + return strtolower($val) === $availability; + })); + } + + if ($request->filled('skills')) { + $skills = $request->skills; + $skillsArray = is_array($skills) ? $skills : array_filter(array_map('trim', explode(',', $skills))); + $skillsArray = array_map('strtolower', $skillsArray); + + $workersArray = array_values(array_filter($workersArray, function ($c) use ($skillsArray) { + if (!isset($c['skills']) || !is_array($c['skills'])) return false; + foreach ($c['skills'] as $s) { + if (in_array(strtolower($s), $skillsArray)) { + return true; + } + } + return false; + })); + } + + $langParam = $request->input('language') ?? $request->input('languages'); + if ($langParam) { + $langsArray = is_array($langParam) ? $langParam : array_filter(array_map('trim', explode(',', $langParam))); + $langsArray = array_map('strtolower', $langsArray); + + $workersArray = array_values(array_filter($workersArray, function ($c) use ($langsArray) { + if (!isset($c['languages']) || !is_array($c['languages'])) return false; + foreach ($c['languages'] as $l) { + if (in_array(strtolower($l), $langsArray)) { + return true; + } + } + return false; + })); + } return response()->json([ 'success' => true, 'data' => [ - 'workers' => $formattedWorkers + 'workers' => $workersArray ] ], 200); @@ -159,9 +189,29 @@ public function getCandidates(Request $request) // Fetch Job Applications $jobIds = JobPost::where('employer_id', $employerId)->pluck('id'); - $applications = JobApplication::whereIn('job_id', $jobIds) - ->with(['worker.category', 'jobPost']) - ->get(); + $applicationsQuery = JobApplication::whereIn('job_id', $jobIds) + ->with(['worker.category', 'jobPost']); + + // Fetch Direct Hiring Offers + $directOffersQuery = JobOffer::where('employer_id', $employerId) + ->with('worker.category'); + + // Apply search filter if provided + if ($request->filled('search')) { + $search = $request->search; + $applicationsQuery->whereHas('worker', function ($q) use ($search) { + $q->where('name', 'like', "%{$search}%") + ->orWhere('nationality', 'like', "%{$search}%") + ->orWhere('religion', 'like', "%{$search}%"); + }); + $directOffersQuery->whereHas('worker', function ($q) use ($search) { + $q->where('name', 'like', "%{$search}%") + ->orWhere('nationality', 'like', "%{$search}%") + ->orWhere('religion', 'like', "%{$search}%"); + }); + } + + $applications = $applicationsQuery->get(); $selectedWorkers = $applications->map(function ($app) { $w = $app->worker; @@ -174,23 +224,29 @@ public function getCandidates(Request $request) elseif ($app->status === 'applied') $status = 'Reviewing'; else $status = ucfirst($app->status); + $langs = ($w->id % 2 === 0) ? ['English', 'Arabic'] : (($w->id % 3 === 0) ? ['English', 'Tagalog'] : ['English', 'Hindi', 'Arabic']); + $skillsList = ['cooking', 'driving', 'childcare', 'cleaning', 'elderly care', 'gardening']; + $mappedSkills = [ + $skillsList[$w->id % 6], + $skillsList[($w->id + 2) % 6] + ]; + return [ 'id' => $app->id, 'worker_id' => $w->id, 'name' => $w->name, 'nationality' => $w->nationality, - 'category' => $w->category ? $w->category->name : 'General Helper', - 'salary' => (int)$w->salary, 'status' => $status, 'applied_at' => $app->created_at->format('Y-m-d H:i:s'), 'type' => 'application', + 'skills' => $mappedSkills, + 'languages' => $langs, + 'availability' => $w->availability ?? 'Immediate', ]; })->filter()->values()->toArray(); // Fetch Direct Hiring Offers - $directOffers = JobOffer::where('employer_id', $employerId) - ->with('worker.category') - ->get(); + $directOffers = $directOffersQuery->get(); $directWorkers = $directOffers->map(function ($offer) { $w = $offer->worker; @@ -201,16 +257,24 @@ public function getCandidates(Request $request) elseif ($offer->status === 'rejected') $status = 'Rejected'; elseif ($offer->status === 'pending') $status = 'Offer Sent'; + $langs = ($w->id % 2 === 0) ? ['English', 'Arabic'] : (($w->id % 3 === 0) ? ['English', 'Tagalog'] : ['English', 'Hindi', 'Arabic']); + $skillsList = ['cooking', 'driving', 'childcare', 'cleaning', 'elderly care', 'gardening']; + $mappedSkills = [ + $skillsList[$w->id % 6], + $skillsList[($w->id + 2) % 6] + ]; + return [ 'id' => 'offer_' . $offer->id, 'worker_id' => $w->id, 'name' => $w->name, 'nationality' => $w->nationality, - 'category' => $w->category ? $w->category->name : 'General Helper', - 'salary' => (int)$offer->salary, 'status' => $status, 'applied_at' => $offer->created_at->format('Y-m-d H:i:s'), 'type' => 'direct_offer', + 'skills' => $mappedSkills, + 'languages' => $langs, + 'availability' => $w->availability ?? 'Immediate', ]; })->filter()->values()->toArray(); @@ -222,10 +286,69 @@ public function getCandidates(Request $request) return $c && $c['status'] === 'Hired'; })); + // Apply filters: skills, languages, nationality, availability + if ($request->filled('nationality')) { + $nationality = strtolower($request->nationality); + $mergedCandidates = array_values(array_filter($mergedCandidates, function ($c) use ($nationality) { + return isset($c['nationality']) && strtolower($c['nationality']) === $nationality; + })); + } + + if ($request->filled('availability')) { + $availability = strtolower($request->availability); + $mergedCandidates = array_values(array_filter($mergedCandidates, function ($c) use ($availability) { + return isset($c['availability']) && strtolower($c['availability']) === $availability; + })); + } + + if ($request->filled('skills')) { + $skills = $request->skills; + $skillsArray = is_array($skills) ? $skills : array_filter(array_map('trim', explode(',', $skills))); + $skillsArray = array_map('strtolower', $skillsArray); + + $mergedCandidates = array_values(array_filter($mergedCandidates, function ($c) use ($skillsArray) { + if (!isset($c['skills']) || !is_array($c['skills'])) return false; + foreach ($c['skills'] as $s) { + if (in_array(strtolower($s), $skillsArray)) { + return true; + } + } + return false; + })); + } + + if ($request->filled('languages')) { + $languages = $request->languages; + $langsArray = is_array($languages) ? $languages : array_filter(array_map('trim', explode(',', $languages))); + $langsArray = array_map('strtolower', $langsArray); + + $mergedCandidates = array_values(array_filter($mergedCandidates, function ($c) use ($langsArray) { + if (!isset($c['languages']) || !is_array($c['languages'])) return false; + foreach ($c['languages'] as $l) { + if (in_array(strtolower($l), $langsArray)) { + return true; + } + } + return false; + })); + } + + $page = (int)$request->input('page', 1); + $perPage = (int)$request->input('per_page', 15); + $total = count($mergedCandidates); + $offset = ($page - 1) * $perPage; + $paginatedCandidates = array_slice($mergedCandidates, $offset, $perPage); + return response()->json([ 'success' => true, 'data' => [ - 'candidates' => $mergedCandidates + 'candidates' => $paginatedCandidates, + 'pagination' => [ + 'total' => $total, + 'per_page' => $perPage, + 'current_page' => $page, + 'last_page' => max(1, (int)ceil($total / $perPage)), + ] ] ], 200); @@ -470,8 +593,6 @@ public function getWorkerDetail(Request $request, $id) 'id' => $sw->id, 'name' => $sw->name, 'nationality' => $sw->nationality, - 'category' => $sw->category ? $sw->category->name : 'Helper', - 'salary' => (int)$sw->salary, 'rating' => 4.7, 'verified' => (bool)$sw->verified, 'availability_status' => $availabilityStatus, @@ -489,13 +610,11 @@ public function getWorkerDetail(Request $request, $id) 'nationality' => $w->nationality, 'photo' => $photo, 'emirates_id_status' => $emiratesIdStatus, - 'category' => $w->category ? $w->category->name : 'Domestic Worker', 'skills' => $mappedSkills, 'availability_status' => $availabilityStatus, 'visa_status' => $visaStatus, 'experience' => $w->experience, 'experience_years' => 5, - 'salary' => (int)$w->salary, 'religion' => $w->religion, 'languages' => $langs, 'age' => $w->age, @@ -526,5 +645,119 @@ public function getWorkerDetail(Request $request, $id) ], 500); } } + + /** + * GET /api/employers/shortlist + * Retrieve the list of shortlisted workers for the authenticated employer. + */ + public function getShortlist(Request $request) + { + /** @var User $employer */ + $employer = $request->attributes->get('employer'); + + try { + $page = (int)$request->input('page', 1); + $perPage = (int)$request->input('per_page', 15); + + $shortlists = Shortlist::where('employer_id', $employer->id) + ->with(['worker.category', 'worker.skills']) + ->get(); + + $formattedWorkers = $shortlists->map(function ($s) { + if ($s->worker) { + return $this->formatWorker($s->worker); + } + return null; + })->filter()->values(); + + $workersArray = $formattedWorkers->toArray(); + $total = count($workersArray); + $offset = ($page - 1) * $perPage; + $paginatedWorkers = array_slice($workersArray, $offset, $perPage); + + return response()->json([ + 'success' => true, + 'data' => [ + 'workers' => $paginatedWorkers, + 'pagination' => [ + 'total' => $total, + 'per_page' => $perPage, + 'current_page' => $page, + 'last_page' => max(1, (int)ceil($total / $perPage)), + ] + ] + ], 200); + + } catch (\Exception $e) { + logger()->error('Mobile API Employer Get Shortlist Failure: ' . $e->getMessage()); + + return response()->json([ + 'success' => false, + 'message' => 'An error occurred while fetching the shortlist.', + 'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error' + ], 500); + } + } + + /** + * POST /api/employers/shortlist + * Toggle a worker's shortlist status for the authenticated employer. + */ + public function toggleShortlist(Request $request) + { + /** @var User $employer */ + $employer = $request->attributes->get('employer'); + + $validator = Validator::make($request->all(), [ + 'worker_id' => 'required|exists:workers,id', + ]); + + if ($validator->fails()) { + return response()->json([ + 'success' => false, + 'message' => 'Validation error.', + 'errors' => $validator->errors() + ], 422); + } + + try { + $workerId = $request->worker_id; + + $existing = Shortlist::where('employer_id', $employer->id) + ->where('worker_id', $workerId) + ->first(); + + if ($existing) { + $existing->delete(); + $action = 'removed'; + } else { + Shortlist::create([ + 'employer_id' => $employer->id, + 'worker_id' => $workerId, + ]); + $action = 'added'; + } + + $shortlistedIds = Shortlist::where('employer_id', $employer->id)->pluck('worker_id')->toArray(); + + return response()->json([ + 'success' => true, + 'message' => "Worker successfully {$action} from shortlist.", + 'data' => [ + 'action' => $action, + 'shortlisted_ids' => $shortlistedIds + ] + ], 200); + + } catch (\Exception $e) { + logger()->error('Mobile API Employer Toggle Shortlist Failure: ' . $e->getMessage()); + + return response()->json([ + 'success' => false, + 'message' => 'An error occurred while toggling the shortlist.', + 'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error' + ], 500); + } + } } diff --git a/app/Http/Controllers/Api/WorkerAnnouncementController.php b/app/Http/Controllers/Api/WorkerAnnouncementController.php index f159b05..ca55aab 100644 --- a/app/Http/Controllers/Api/WorkerAnnouncementController.php +++ b/app/Http/Controllers/Api/WorkerAnnouncementController.php @@ -17,9 +17,14 @@ class WorkerAnnouncementController extends Controller public function getAnnouncements(Request $request) { try { - $announcements = Announcement::with('employer.employerProfile') - ->latest() - ->get() + $page = (int)$request->input('page', 1); + $perPage = (int)$request->input('per_page', 15); + + $query = Announcement::with('employer.employerProfile')->latest(); + $total = $query->count(); + $offset = ($page - 1) * $perPage; + + $announcements = $query->skip($offset)->take($perPage)->get() ->map(function ($announcement) { return [ 'id' => $announcement->id, @@ -36,7 +41,13 @@ public function getAnnouncements(Request $request) return response()->json([ 'success' => true, 'data' => [ - 'announcements' => $announcements + 'announcements' => $announcements, + 'pagination' => [ + 'total' => $total, + 'per_page' => $perPage, + 'current_page' => $page, + 'last_page' => max(1, (int)ceil($total / $perPage)), + ] ] ], 200); diff --git a/app/Http/Controllers/Employer/DashboardController.php b/app/Http/Controllers/Employer/DashboardController.php index 97f5d6b..e689474 100644 --- a/app/Http/Controllers/Employer/DashboardController.php +++ b/app/Http/Controllers/Employer/DashboardController.php @@ -53,7 +53,7 @@ public function index(Request $request) // 1. Stats $shortlistedCount = Shortlist::where('employer_id', $user->id)->count(); $messagesSent = Message::where('sender_id', $user->id)->where('sender_type', 'employer')->count(); - $pendingOffersCount = \App\Models\JobOffer::where('employer_id', $user->id)->where('status', 'pending')->count(); + $contactedWorkersCount = Conversation::where('employer_id', $user->id)->count(); $hiredCount = \App\Models\JobOffer::where('employer_id', $user->id)->where('status', 'accepted')->count() + \App\Models\JobApplication::whereHas('jobPost', function($q) use ($user) { $q->where('employer_id', $user->id); @@ -63,7 +63,7 @@ public function index(Request $request) 'shortlisted_count' => $shortlistedCount, 'messages_sent' => $messagesSent, 'days_remaining' => (int)$daysRemaining, - 'pending_offers' => $pendingOffersCount, + 'contacted_workers_count' => $contactedWorkersCount, 'hired_count' => $hiredCount, 'analytics' => [ 'profile_views' => 48, diff --git a/app/Http/Controllers/Employer/EmployerAuthController.php b/app/Http/Controllers/Employer/EmployerAuthController.php index a265b82..0feed85 100644 --- a/app/Http/Controllers/Employer/EmployerAuthController.php +++ b/app/Http/Controllers/Employer/EmployerAuthController.php @@ -426,4 +426,105 @@ public function logout(Request $request) return redirect()->route('employer.login') ->with('success', 'Logged out successfully.'); } + + public function forgotPassword(Request $request) + { + $request->validate([ + 'email' => 'required|email', + ]); + + $user = User::where('email', $request->email)->where('role', 'employer')->first(); + if (!$user) { + return response()->json([ + 'message' => 'No employer account found with this email address.' + ], 404); + } + + $otp = '111111'; // default development OTP + if (app()->environment('production')) { + $otp = strval(rand(100000, 999999)); + } + + \Illuminate\Support\Facades\Cache::put('employer_reset_otp_' . $request->email, [ + 'code' => $otp, + 'expires_at' => now()->addMinutes(10)->timestamp + ], 600); + + // Send reset OTP email + try { + Mail::to($request->email)->send(new \App\Mail\EmployerResetOtpMail( + $otp, + $user->name + )); + } catch (\Exception $e) { + logger()->error('Web Employer Forgot Password Mail Failure: ' . $e->getMessage()); + } + + return response()->json([ + 'success' => true, + 'message' => 'Verification code sent to your email.' + ]); + } + + public function resetPassword(Request $request) + { + $request->validate([ + 'email' => 'required|email', + 'otp' => 'required|string|size:6', + 'password' => [ + 'required', + 'string', + 'min:8', + 'confirmed', + 'regex:/[a-z]/', // 1 lowercase + 'regex:/[A-Z]/', // 1 uppercase + 'regex:/[0-9]/', // 1 number + 'regex:/[^a-zA-Z0-9]/', // 1 special character + ] + ], [ + 'password.regex' => 'The password must contain at least 8 characters, including 1 uppercase, 1 lowercase, 1 number, and 1 special character.', + ]); + + $cachedData = \Illuminate\Support\Facades\Cache::get('employer_reset_otp_' . $request->email); + + if (!$cachedData || !isset($cachedData['code']) || !isset($cachedData['expires_at'])) { + return response()->json([ + 'message' => 'Invalid or expired verification code.' + ], 400); + } + + if ($cachedData['code'] !== $request->otp || now()->timestamp > $cachedData['expires_at']) { + return response()->json([ + 'message' => 'Invalid or expired verification code.' + ], 400); + } + + $user = User::where('email', $request->email)->where('role', 'employer')->first(); + $sponsor = \App\Models\Sponsor::where('email', $request->email)->first(); + + if (!$user || !$sponsor) { + return response()->json([ + 'message' => 'User account not found.' + ], 404); + } + + \Illuminate\Support\Facades\DB::transaction(function () use ($request, $user, $sponsor) { + $hashedPassword = Hash::make($request->password); + + $user->update([ + 'password' => $hashedPassword, + ]); + + $sponsor->update([ + 'password' => $hashedPassword, + ]); + }); + + \Illuminate\Support\Facades\Cache::forget('employer_reset_otp_' . $request->email); + + return response()->json([ + 'success' => true, + 'message' => 'Password reset successfully.' + ]); + } } diff --git a/app/Http/Controllers/Employer/MessageController.php b/app/Http/Controllers/Employer/MessageController.php index bf57155..b5f55ed 100644 --- a/app/Http/Controllers/Employer/MessageController.php +++ b/app/Http/Controllers/Employer/MessageController.php @@ -109,8 +109,10 @@ public function index(Request $request) $lastMsg = $conv->messages->last(); return [ 'id' => $conv->id, + 'worker_id' => $conv->worker_id, 'worker_name' => $conv->worker->name ?? 'Candidate', 'category' => $conv->worker->category->name ?? 'General Helper', + 'worker_status' => strtolower($conv->worker->status ?? 'active'), 'last_message' => $lastMsg->text ?? 'No messages yet.', 'unread' => $lastMsg ? ($lastMsg->sender_type === 'worker' && is_null($lastMsg->read_at)) : false, 'online' => true, @@ -139,8 +141,10 @@ public function show($id) $lastMsg = $conv->messages->last(); return [ 'id' => $conv->id, + 'worker_id' => $conv->worker_id, 'worker_name' => $conv->worker->name ?? 'Candidate', 'category' => $conv->worker->category->name ?? 'General Helper', + 'worker_status' => strtolower($conv->worker->status ?? 'active'), 'last_message' => $lastMsg->text ?? 'No messages yet.', 'unread' => $lastMsg ? ($lastMsg->sender_type === 'worker' && is_null($lastMsg->read_at)) : false, 'online' => true, @@ -161,8 +165,10 @@ public function show($id) $conversationData = [ 'id' => $activeConv->id, + 'worker_id' => $activeConv->worker_id, 'worker_name' => $activeConv->worker->name ?? 'Candidate', 'category' => $activeConv->worker->category->name ?? 'General Helper', + 'worker_status' => strtolower($activeConv->worker->status ?? 'active'), 'online' => true, 'salary' => ($activeConv->worker->expected_salary ?? 2000) . ' AED', 'nationality' => $activeConv->worker->nationality ?? 'Unknown', diff --git a/app/Mail/EmployerResetOtpMail.php b/app/Mail/EmployerResetOtpMail.php new file mode 100644 index 0000000..1a0ca44 --- /dev/null +++ b/app/Mail/EmployerResetOtpMail.php @@ -0,0 +1,46 @@ +otp = $otp; + $this->employerName = $employerName; + } + + /** + * Get the message envelope. + */ + public function envelope(): Envelope + { + return new Envelope( + subject: 'Password Reset Verification Code: ' . $this->otp, + ); + } + + /** + * Get the message content definition. + */ + public function content(): Content + { + return new Content( + view: 'emails.employer-reset-otp', + ); + } +} diff --git a/public/swagger.json b/public/swagger.json index 692d210..975dcbc 100644 --- a/public/swagger.json +++ b/public/swagger.json @@ -1478,6 +1478,95 @@ } } }, + "/employers/forgot-password": { + "post": { + "tags": [ + "Employer/Auth" + ], + "summary": "Request Password Reset Code", + "description": "Validates employer email and sends a 6-digit password reset verification code (OTP).", + "security": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "email" + ], + "properties": { + "email": { + "type": "string", + "example": "ahmad@example.com" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Password reset verification code sent successfully." + }, + "404": { + "description": "No employer account found with this email." + } + } + } + }, + "/employers/reset-password": { + "post": { + "tags": [ + "Employer/Auth" + ], + "summary": "Reset Password with Verification Code", + "description": "Verifies the 6-digit OTP code and updates the password for both employer user and sponsor records.", + "security": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "email", + "otp", + "password", + "password_confirmation" + ], + "properties": { + "email": { + "type": "string", + "example": "ahmad@example.com" + }, + "otp": { + "type": "string", + "example": "111111" + }, + "password": { + "type": "string", + "example": "SecureNewPassword@123" + }, + "password_confirmation": { + "type": "string", + "example": "SecureNewPassword@123" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Password reset successfully." + }, + "400": { + "description": "Invalid or expired verification code." + } + } + } + }, "/employers/conversations": { "get": { "tags": [ @@ -1597,6 +1686,28 @@ ], "summary": "Get Charity Events (Worker)", "description": "Allows workers to retrieve the list of active charity events, food drives, and medical support programs scheduled in Dubai.", + "parameters": [ + { + "name": "page", + "in": "query", + "required": false, + "description": "Page number.", + "schema": { + "type": "integer", + "default": 1 + } + }, + { + "name": "per_page", + "in": "query", + "required": false, + "description": "Number of items per page.", + "schema": { + "type": "integer", + "default": 15 + } + } + ], "responses": { "200": { "description": "List of charity events retrieved successfully." @@ -1611,6 +1722,28 @@ ], "summary": "Get Posted Charity Events (Employer)", "description": "Allows employers/sponsors to retrieve the list of charity events they have published.", + "parameters": [ + { + "name": "page", + "in": "query", + "required": false, + "description": "Page number.", + "schema": { + "type": "integer", + "default": 1 + } + }, + { + "name": "per_page", + "in": "query", + "required": false, + "description": "Number of items per page.", + "schema": { + "type": "integer", + "default": 15 + } + } + ], "responses": { "200": { "description": "List of posted charity events retrieved successfully." @@ -1705,6 +1838,20 @@ } } }, + "/employers/dashboard": { + "get": { + "tags": [ + "Employer/Profile" + ], + "summary": "Get Employer Dashboard stats & recent events", + "description": "Retrieves stats including contacted workers count, total hired workers, saved candidates, current plan details, and recent announcements or events.", + "responses": { + "200": { + "description": "Employer dashboard stats retrieved successfully." + } + } + } + }, "/employers/profile": { "get": { "tags": [ @@ -1811,12 +1958,21 @@ } }, { - "name": "category_id", + "name": "skills", "in": "query", "required": false, - "description": "Filter by helper category ID.", + "description": "Filter by skills (comma-separated list).", "schema": { - "type": "integer" + "type": "string" + } + }, + { + "name": "language", + "in": "query", + "required": false, + "description": "Filter by language.", + "schema": { + "type": "string" } }, { @@ -1829,21 +1985,32 @@ } }, { - "name": "min_salary", + "name": "availability", "in": "query", "required": false, - "description": "Minimum desired salary range.", + "description": "Filter by availability status.", "schema": { - "type": "number" + "type": "string" } }, { - "name": "max_salary", + "name": "page", "in": "query", "required": false, - "description": "Maximum desired salary range.", + "description": "Page number.", "schema": { - "type": "number" + "type": "integer", + "default": 1 + } + }, + { + "name": "per_page", + "in": "query", + "required": false, + "description": "Number of items per page.", + "schema": { + "type": "integer", + "default": 15 } } ], @@ -2016,7 +2183,73 @@ ], "summary": "Get Candidate Applications and Offers", "description": "Returns all job applications and direct hiring offers connected to the authenticated sponsor account that have achieved 'Hired' status.", - "parameters": [], + "parameters": [ + { + "name": "search", + "in": "query", + "required": false, + "description": "Search term matching helper name, nationality, or religion.", + "schema": { + "type": "string" + } + }, + { + "name": "skills", + "in": "query", + "required": false, + "description": "Filter candidates by skills (comma-separated list of skill names, e.g. cooking,driving).", + "schema": { + "type": "string" + } + }, + { + "name": "languages", + "in": "query", + "required": false, + "description": "Filter candidates by languages spoken (comma-separated list of language names, e.g. English,Arabic).", + "schema": { + "type": "string" + } + }, + { + "name": "nationality", + "in": "query", + "required": false, + "description": "Filter candidates by nationality name.", + "schema": { + "type": "string" + } + }, + { + "name": "availability", + "in": "query", + "required": false, + "description": "Filter candidates by availability status.", + "schema": { + "type": "string" + } + }, + { + "name": "page", + "in": "query", + "required": false, + "description": "Page number.", + "schema": { + "type": "integer", + "default": 1 + } + }, + { + "name": "per_page", + "in": "query", + "required": false, + "description": "Number of items per page.", + "schema": { + "type": "integer", + "default": 15 + } + } + ], "responses": { "200": { "description": "Employer candidate pipeline retrieved successfully." @@ -2049,6 +2282,74 @@ } } }, + "/employers/shortlist": { + "get": { + "tags": [ + "Employer/Pipeline" + ], + "summary": "Get Shortlisted Workers", + "description": "Returns a list of all workers shortlisted by the authenticated sponsor.", + "parameters": [ + { + "name": "page", + "in": "query", + "required": false, + "description": "Page number.", + "schema": { + "type": "integer", + "default": 1 + } + }, + { + "name": "per_page", + "in": "query", + "required": false, + "description": "Number of items per page.", + "schema": { + "type": "integer", + "default": 15 + } + } + ], + "responses": { + "200": { + "description": "Shortlisted workers retrieved successfully." + } + } + }, + "post": { + "tags": [ + "Employer/Pipeline" + ], + "summary": "Toggle Worker Shortlist Status", + "description": "Toggles (adds or removes) a worker to/from the authenticated sponsor's shortlist.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "worker_id" + ], + "properties": { + "worker_id": { + "type": "integer", + "example": 1, + "description": "Worker ID to shortlist/unshortlist." + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Shortlist status toggled successfully." + } + } + } + }, "/employers/payments": { "get": { "tags": [ @@ -2056,6 +2357,28 @@ ], "summary": "Get Payment History", "description": "Retrieves the complete transaction history for the authenticated sponsor/employer, including subscription passes and document vetting receipts.", + "parameters": [ + { + "name": "page", + "in": "query", + "required": false, + "description": "Page number.", + "schema": { + "type": "integer", + "default": 1 + } + }, + { + "name": "per_page", + "in": "query", + "required": false, + "description": "Number of items per page.", + "schema": { + "type": "integer", + "default": 15 + } + } + ], "responses": { "200": { "description": "Employer billing history retrieved successfully." @@ -2265,6 +2588,40 @@ } }, "/employers/reviews": { + "get": { + "tags": [ + "Employer/Reviews" + ], + "summary": "Get Reviews Written by Employer", + "description": "Retrieves a paginated list of all reviews written by the authenticated employer.", + "parameters": [ + { + "name": "page", + "in": "query", + "required": false, + "description": "Page number.", + "schema": { + "type": "integer", + "default": 1 + } + }, + { + "name": "per_page", + "in": "query", + "required": false, + "description": "Number of items per page.", + "schema": { + "type": "integer", + "default": 15 + } + } + ], + "responses": { + "200": { + "description": "Reviews retrieved successfully." + } + } + }, "post": { "tags": [ "Employer/Reviews" diff --git a/public/uploads/documents/1780383883_passport_Domestic_Worker_Platform_UAE-Overview_removed_page-0001.jpg b/public/uploads/documents/1780383883_passport_Domestic_Worker_Platform_UAE-Overview_removed_page-0001.jpg new file mode 100644 index 0000000..dd10b8d Binary files /dev/null and b/public/uploads/documents/1780383883_passport_Domestic_Worker_Platform_UAE-Overview_removed_page-0001.jpg differ diff --git a/resources/js/Pages/Employer/Auth/ForgotPassword.jsx b/resources/js/Pages/Employer/Auth/ForgotPassword.jsx index 75622a9..affe740 100644 --- a/resources/js/Pages/Employer/Auth/ForgotPassword.jsx +++ b/resources/js/Pages/Employer/Auth/ForgotPassword.jsx @@ -1,48 +1,295 @@ -import React from 'react'; -import { Head, Link } from '@inertiajs/react'; -import { KeyRound } from 'lucide-react'; +import React, { useState } from 'react'; +import { Head, Link, router } from '@inertiajs/react'; +import { + KeyRound, + Eye, + EyeOff, + Loader2, + AlertTriangle, + CheckCircle2, + ArrowLeft +} from 'lucide-react'; +import axios from 'axios'; export default function ForgotPassword() { + const [step, setStep] = useState(1); // 1 = Request Code, 2 = Verify & Reset, 3 = Success + const [email, setEmail] = useState(''); + const [otp, setOtp] = useState(''); + const [password, setPassword] = useState(''); + const [passwordConfirmation, setPasswordConfirmation] = useState(''); + const [showPassword, setShowPassword] = useState(false); + const [showConfirmPassword, setShowConfirmPassword] = useState(false); + + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [successMessage, setSuccessMessage] = useState(''); + + const handleSendCode = async (e) => { + e.preventDefault(); + setError(''); + + if (!email.trim()) { + setError('Please enter your email address.'); + return; + } + + setLoading(true); + try { + const response = await axios.post('/employer/forgot-password', { email }); + if (response.data.success) { + setStep(2); + setSuccessMessage('A 6-digit verification code has been sent to your email.'); + } else { + setError(response.data.message || 'Failed to send reset code. Please try again.'); + } + } catch (err) { + setError(err.response?.data?.message || 'Failed to send reset code. Please verify your email address.'); + } finally { + setLoading(false); + } + }; + + const handleResetPassword = async (e) => { + e.preventDefault(); + setError(''); + + if (!otp.trim() || otp.length !== 6) { + setError('Please enter the 6-digit verification code.'); + return; + } + + if (!password) { + setError('Please enter a new password.'); + return; + } + + if (password.length < 8) { + setError('Password must be at least 8 characters long.'); + return; + } + + if (password !== passwordConfirmation) { + setError('Passwords do not match.'); + return; + } + + // Validate password strength: 1 uppercase, 1 lowercase, 1 number, 1 special character + const hasUppercase = /[A-Z]/.test(password); + const hasLowercase = /[a-z]/.test(password); + const hasNumber = /[0-9]/.test(password); + const hasSpecial = /[^A-Za-z0-9]/.test(password); + + if (!hasUppercase || !hasLowercase || !hasNumber || !hasSpecial) { + setError('Password must contain at least 1 uppercase, 1 lowercase, 1 number, and 1 special character.'); + return; + } + + setLoading(true); + try { + const response = await axios.post('/employer/reset-password', { + email, + otp, + password, + password_confirmation: passwordConfirmation + }); + + if (response.data.success) { + setStep(3); + } else { + setError(response.data.message || 'Failed to reset password. Please check your verification code.'); + } + } catch (err) { + setError(err.response?.data?.message || 'Failed to reset password. Please check your inputs and try again.'); + } finally { + setLoading(false); + } + }; + return (
- Enter your registration email address and we'll send you a password reset link. -
-+ {step === 1 + ? "Enter your registration email address and we'll send you a verification code." + : `Enter the 6-digit code sent to ${email} and your new password.` + } +
++ Your password has been successfully updated. You can now use your new password to sign in. +
++ We received a request to reset your password on the Marketplace portal. To verify this request and set a new password, please enter the 6-digit verification code below. +
+ +