diff --git a/app/Http/Controllers/Api/EmployerProfileController.php b/app/Http/Controllers/Api/EmployerProfileController.php index d93cfb2..fa38aa6 100644 --- a/app/Http/Controllers/Api/EmployerProfileController.php +++ b/app/Http/Controllers/Api/EmployerProfileController.php @@ -23,6 +23,7 @@ public function getProfile(Request $request) $employer = $request->attributes->get('employer'); try { + $employer->load('employerReviews.worker'); $profile = $employer->employerProfile; if (!$profile) { @@ -47,6 +48,9 @@ public function getProfile(Request $request) 'card_number' => $profile->emirates_id_card_number, 'full_name' => $profile->emirates_id_name, 'gender' => $profile->emirates_id_gender, + 'rating' => $employer->rating, + 'reviews_count' => $employer->reviews_count, + 'reviews' => $employer->employerReviews, 'emirates_id' => [ 'emirates_id_number' => $profile->emirates_id_number, 'name' => $profile->emirates_id_name, @@ -267,6 +271,8 @@ public function updateProfile(Request $request) $profile->save(); + $employer->load('employerReviews.worker'); + $employerProfile = [ 'name' => $employer->name, 'email' => $employer->email, @@ -280,6 +286,9 @@ public function updateProfile(Request $request) 'card_number' => $profile->emirates_id_card_number, 'full_name' => $profile->emirates_id_name, 'gender' => $profile->emirates_id_gender, + 'rating' => $employer->rating, + 'reviews_count' => $employer->reviews_count, + 'reviews' => $employer->employerReviews, 'emirates_id' => [ 'emirates_id_number' => $profile->emirates_id_number, 'name' => $profile->emirates_id_name, diff --git a/app/Http/Controllers/Api/WorkerAuthController.php b/app/Http/Controllers/Api/WorkerAuthController.php index 4ce6c8c..84ac32d 100644 --- a/app/Http/Controllers/Api/WorkerAuthController.php +++ b/app/Http/Controllers/Api/WorkerAuthController.php @@ -266,7 +266,7 @@ public function setupProfile(Request $request) // Clear the OTP verification gate Cache::forget('worker_otp_verified_' . $identifier); - $worker->load(['skills']); + $worker->load(['skills', 'documents', 'reviews.employer.employerProfile', 'employerReviews.employer.employerProfile']); return response()->json([ 'success' => true, @@ -523,7 +523,7 @@ public function register(Request $request) return $worker; }); - $result->load(['skills', 'documents']); + $result->load(['skills', 'documents', 'reviews.employer.employerProfile', 'employerReviews.employer.employerProfile']); return response()->json([ 'success' => true, @@ -591,7 +591,7 @@ public function register(Request $request) 'success' => true, 'message' => 'Worker logged in successfully.', 'data' => [ - 'worker' => $worker->load(['skills', 'documents']), + 'worker' => $worker->load(['skills', 'documents', 'reviews.employer.employerProfile', 'employerReviews.employer.employerProfile']), 'token' => $apiToken ] ], 200); diff --git a/app/Http/Controllers/Api/WorkerProfileController.php b/app/Http/Controllers/Api/WorkerProfileController.php index fbc5b4f..688e956 100644 --- a/app/Http/Controllers/Api/WorkerProfileController.php +++ b/app/Http/Controllers/Api/WorkerProfileController.php @@ -28,7 +28,7 @@ public function getProfile(Request $request) /** @var Worker $worker */ $worker = $request->attributes->get('worker'); - $worker->load(['skills', 'documents']); + $worker->load(['skills', 'documents', 'reviews.employer.employerProfile', 'employerReviews.employer.employerProfile']); $worker->makeHidden(['email', 'religion', 'availability', 'bio', 'country', 'city', 'area']); @@ -240,7 +240,7 @@ public function updateProfile(Request $request) } }); - $worker->load(['skills', 'documents']); + $worker->load(['skills', 'documents', 'reviews.employer.employerProfile', 'employerReviews.employer.employerProfile']); $worker->makeHidden(['email', 'religion', 'availability', 'bio', 'country', 'city', 'area']); @@ -286,7 +286,7 @@ public function goLive(Request $request) 'success' => true, 'message' => 'Your profile is now live! Status set to Active.', 'data' => [ - 'worker' => $worker->load(['skills', 'documents']) + 'worker' => $worker->load(['skills', 'documents', 'reviews.employer.employerProfile', 'employerReviews.employer.employerProfile']) ] ], 200); } catch (\Exception $e) { @@ -339,7 +339,7 @@ public function toggleAvailability(Request $request) 'still_looking' => $stillLooking, 'status' => $newStatus, 'data' => [ - 'worker' => $worker->load(['skills', 'documents']) + 'worker' => $worker->load(['skills', 'documents', 'reviews.employer.employerProfile', 'employerReviews.employer.employerProfile']) ] ], 200); @@ -373,7 +373,7 @@ public function markHired(Request $request) 'success' => true, 'message' => 'Congratulations! You have been successfully marked as Hired. Your profile has been automatically removed from active search.', 'data' => [ - 'worker' => $worker->load(['skills', 'documents']) + 'worker' => $worker->load(['skills', 'documents', 'reviews.employer.employerProfile', 'employerReviews.employer.employerProfile']) ] ], 200); } catch (\Exception $e) { @@ -589,7 +589,7 @@ public function getDashboard(Request $request) $currentSponsor = null; $acceptedOffer = JobOffer::where('worker_id', $worker->id) ->where('status', 'accepted') - ->with('employer.employerProfile') + ->with(['employer.employerProfile', 'employer.employerReviews.worker']) ->first(); if ($acceptedOffer && $acceptedOffer->employer) { @@ -605,6 +605,9 @@ public function getDashboard(Request $request) 'phone' => $emp->phone, 'hired_at' => $acceptedOffer->updated_at->toIso8601String(), 'hired_at_formatted' => $acceptedOffer->updated_at->format('M d, Y'), + 'rating' => $emp->rating, + 'reviews_count' => $emp->reviews_count, + 'reviews' => $emp->employerReviews, ]; } @@ -685,7 +688,7 @@ public function getEmployers(Request $request) try { $query = JobOffer::where('worker_id', $worker->id) - ->with(['employer.employerProfile']); + ->with(['employer.employerProfile', 'employer.employerReviews.worker']); // Filtering by status if ($request->filled('status')) { @@ -745,6 +748,9 @@ public function getEmployers(Request $request) 'company_name' => $empProfile->company_name ?? 'Private Sponsor', 'city' => $empProfile->city ?? null, 'nationality' => $empProfile->nationality ?? null, + 'rating' => $emp->rating, + 'reviews_count' => $emp->reviews_count, + 'reviews' => $emp->employerReviews, ] : null ]; }); diff --git a/app/Http/Controllers/Employer/ShortlistController.php b/app/Http/Controllers/Employer/ShortlistController.php index 2e93e86..353a5f5 100644 --- a/app/Http/Controllers/Employer/ShortlistController.php +++ b/app/Http/Controllers/Employer/ShortlistController.php @@ -56,22 +56,21 @@ public function index(Request $request) return null; } - // Map languages with country names - $langs = ($w->id % 2 === 0) ? ['English', 'Arabic'] : (($w->id % 3 === 0) ? ['English', 'Tagalog'] : ['English', 'Hindi', 'Arabic']); + // Map languages from database comma-separated string + $langs = $w->language ? array_map('trim', explode(',', $w->language)) : ['English']; // Preferred job types: full-time / part-time / live-in / live-out $jobTypes = ['full-time', 'part-time', 'live-in', 'live-out']; - $preferredJobType = $jobTypes[$w->id % 4]; + $preferredJobType = $w->preferred_job_type ?? $jobTypes[$w->id % 4]; // Emirates ID verification status (dynamic passport status) $emiratesIdStatus = $w->emirates_id_status; - // Exact Skills mapping: cooking, driving, childcare, cleaning, elderly care, gardening - $skillsList = ['cooking', 'driving', 'childcare', 'cleaning', 'elderly care', 'gardening']; - $mappedSkills = [ - $skillsList[$w->id % 6], - $skillsList[($w->id + 2) % 6] - ]; + // Map skills dynamically from database + $mappedSkills = $w->skills->pluck('name')->toArray(); + if (empty($mappedSkills)) { + $mappedSkills = ['cooking', 'cleaning']; + } // Visa status $visaStatus = $w->visa_status; @@ -86,13 +85,16 @@ public function index(Request $request) return [ 'id' => $w->id, 'name' => $w->name, + 'phone' => $w->phone, + 'gender' => $w->gender ?? 'Female', 'nationality' => $w->nationality, 'photo' => $photo, 'emirates_id_status' => $emiratesIdStatus, 'passport_status' => $w->passport_status, + 'category' => 'Domestic Worker', + 'main_profession' => $w->main_profession, 'skills' => $mappedSkills, 'visa_status' => $visaStatus, - 'gender' => $w->gender ?? 'Female', 'experience' => $w->experience, 'salary' => (int) $w->salary, 'religion' => $w->religion, @@ -100,6 +102,7 @@ public function index(Request $request) 'age' => $w->age, 'verified' => (bool) $w->verified, 'preferred_job_type' => $preferredJobType, + 'live_in_out' => $w->live_in_out ?? 'Live-in', 'bio' => $w->bio, 'rating' => $rating, 'reviews_count' => $reviewsCount, @@ -111,8 +114,177 @@ public function index(Request $request) ]; })->filter()->values()->toArray(); + // Apply request filters: preferred_location, job_type, live_in_out, nationality, in_country, visa_status + if ($request->filled('preferred_location')) { + $prefLoc = $request->preferred_location; + $locsArray = is_array($prefLoc) ? $prefLoc : array_filter(array_map('trim', explode(',', $prefLoc))); + $locsArray = array_map('strtolower', $locsArray); + + $locationMapping = [ + 'dubai' => ['dubai', 'marina', 'barsha', 'nahda', 'jumeirah', 'deira', 'downtown', 'silicon', 'sports', 'motor', 'jlt', 'jbr', 'meydan', 'ranches', 'bay', 'mirdif', 'quoz'], + 'abu dhabi' => ['abu dhabi', 'yas', 'khalifa', 'reem', 'saadiyat', 'raha', 'mussafah', 'zahiyah', 'karamah'], + 'oman' => ['oman', 'muscat', 'salalah', 'sohar', 'nizwa', 'sur', 'ibri', 'rustaq'] + ]; + + $shortlistedWorkers = array_values(array_filter($shortlistedWorkers, function ($c) use ($locsArray, $locationMapping) { + if (!isset($c['preferred_location'])) return false; + $wLoc = strtolower($c['preferred_location']); + foreach ($locsArray as $l) { + if ($l === 'all') return true; + $allowedKeywords = $locationMapping[$l] ?? [$l]; + foreach ($allowedKeywords as $keyword) { + if (str_contains($wLoc, $keyword)) { + return true; + } + } + } + return false; + })); + } + + $jobTypeParam = $request->input('job_type') ?? $request->input('preferred_job_type'); + if ($jobTypeParam) { + $jobTypesArray = is_array($jobTypeParam) ? $jobTypeParam : array_filter(array_map('trim', explode(',', $jobTypeParam))); + $jobTypesArray = array_map('strtolower', $jobTypesArray); + + $shortlistedWorkers = array_values(array_filter($shortlistedWorkers, function ($c) use ($jobTypesArray) { + if (!isset($c['preferred_job_type'])) return false; + $wJobType = strtolower($c['preferred_job_type']); + foreach ($jobTypesArray as $jt) { + if (str_contains($wJobType, $jt)) { + return true; + } + } + return false; + })); + } + + $accParam = $request->input('live_in_out') ?? $request->input('accommodation_type') ?? $request->input('accomadation_type'); + if ($accParam) { + $accsArray = is_array($accParam) ? $accParam : array_filter(array_map('trim', explode(',', $accParam))); + $accsArray = array_map(function($val) { + return str_replace(['_', '-'], ' ', strtolower($val)); + }, $accsArray); + + $shortlistedWorkers = array_values(array_filter($shortlistedWorkers, function ($c) use ($accsArray) { + if (!isset($c['live_in_out'])) return false; + $wAcc = str_replace(['_', '-'], ' ', strtolower($c['live_in_out'])); + foreach ($accsArray as $a) { + if (str_contains($wAcc, $a)) { + return true; + } + } + return false; + })); + } + + if ($request->filled('nationality')) { + $natInput = $request->nationality; + $natsArray = is_array($natInput) ? $natInput : array_filter(array_map('trim', explode(',', $natInput))); + $natsArray = array_map('strtolower', $natsArray); + + $shortlistedWorkers = array_values(array_filter($shortlistedWorkers, function ($c) use ($natsArray) { + if (!isset($c['nationality'])) return false; + $wNat = strtolower($c['nationality']); + foreach ($natsArray as $n) { + if (str_contains($wNat, $n) || $wNat === $n) { + return true; + } + } + return false; + })); + } + if ($request->filled('main_profession')) { + $mainProfession = strtolower($request->main_profession); + $shortlistedWorkers = array_values(array_filter($shortlistedWorkers, function ($c) use ($mainProfession) { + return isset($c['main_profession']) && strtolower($c['main_profession']) === $mainProfession; + })); + } + if ($request->filled('gender')) { + $gender = strtolower($request->gender); + $shortlistedWorkers = array_values(array_filter($shortlistedWorkers, function ($c) use ($gender) { + return isset($c['gender']) && strtolower($c['gender']) === $gender; + })); + } + if ($request->has('in_country')) { + $inCountryVal = $request->input('in_country'); + if ($inCountryVal !== null && $inCountryVal !== '') { + $isInCountry = filter_var($inCountryVal, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + if ($isInCountry === null) { + $strVal = strtolower(trim($inCountryVal)); + if ($strVal === 'in' || $strVal === 'in_country' || $strVal === 'yes') { + $isInCountry = true; + } elseif ($strVal === 'out' || $strVal === 'out_country' || $strVal === 'no') { + $isInCountry = false; + } + } + if ($isInCountry !== null) { + $shortlistedWorkers = array_values(array_filter($shortlistedWorkers, function ($c) use ($isInCountry) { + return isset($c['in_country']) && (bool)$c['in_country'] === $isInCountry; + })); + } + } + } + + if ($request->has('out_country')) { + $outCountryVal = $request->input('out_country'); + if ($outCountryVal !== null && $outCountryVal !== '') { + $isOutCountry = filter_var($outCountryVal, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + if ($isOutCountry === null) { + $strVal = strtolower(trim($outCountryVal)); + if ($strVal === 'out' || $strVal === 'out_country' || $strVal === 'yes') { + $isOutCountry = true; + } elseif ($strVal === 'in' || $strVal === 'in_country' || $strVal === 'no') { + $isOutCountry = false; + } + } + if ($isOutCountry !== null) { + $shortlistedWorkers = array_values(array_filter($shortlistedWorkers, function ($c) use ($isOutCountry) { + return isset($c['in_country']) && (bool)$c['in_country'] !== $isOutCountry; + })); + } + } + } + + $visaParam = $request->input('visa_status') ?? $request->input('next_visa_type') ?? $request->input('visa_type'); + if ($visaParam) { + $visasArray = is_array($visaParam) ? $visaParam : array_filter(array_map('trim', explode(',', $visaParam))); + $visasArray = array_map('strtolower', $visasArray); + + $shortlistedWorkers = array_values(array_filter($shortlistedWorkers, function ($c) use ($visasArray) { + if (!isset($c['visa_status'])) return false; + $isInCountry = isset($c['in_country']) && (bool)$c['in_country']; + if (!$isInCountry) { + return false; + } + $wVisa = strtolower($c['visa_status']); + foreach ($visasArray as $v) { + if (str_contains($wVisa, $v)) { + return true; + } + } + return false; + })); + } + + $nationalitiesResponse = app(\App\Http\Controllers\Api\WorkerAuthController::class)->nationalities(new \Illuminate\Http\Request(['per_page' => 500])); + $nationalitiesData = json_decode($nationalitiesResponse->getContent(), true); + $dbNationalities = collect($nationalitiesData['data']['nationalities'] ?? [])->pluck('name')->filter()->toArray(); + + $filtersMetadata = [ + 'nationalities' => array_merge(['All Nationalities'], $dbNationalities), + 'professions' => ['All Professions', 'Housemaid', 'Nanny', 'Cook', 'Driver', 'Caregiver'], + 'experienceLevels' => ['All Experience', '1-2 Years', '3-5 Years', '5+ Years'], + 'religions' => ['All Religions', 'Christian', 'Muslim', 'Hindu', 'Buddhist'], + 'languages' => ['All Languages', 'English', 'Arabic', 'Hindi', 'Tagalog'], + 'workTypes' => ['All Types', 'full-time', 'part-time', 'live-in', 'live-out'], + 'skills' => ['All Skills', 'cooking', 'driving', 'childcare', 'cleaning', 'elderly care', 'gardening'], + 'visaStatuses' => ['All Visa Statuses', 'Residence Visa', 'Tourist Visa', 'Employment Visa'], + ]; + return Inertia::render('Employer/Shortlist', [ 'shortlistedWorkers' => $shortlistedWorkers, + 'filtersMetadata' => $filtersMetadata, ]); } diff --git a/app/Models/User.php b/app/Models/User.php index 63dc517..b3b9aa3 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -46,6 +46,34 @@ public function hasActiveSubscription(): bool return $this->subscription_status === 'active' && ($this->subscription_expires_at === null || $this->subscription_expires_at > now()); } + protected $appends = [ + 'rating', + 'reviews_count', + ]; + + public function employerReviews() + { + return $this->hasMany(EmployerReview::class, 'employer_id'); + } + + public function getRatingAttribute() + { + if ($this->role !== 'employer') { + return 0.0; + } + $dbReviews = $this->employerReviews; + $count = $dbReviews->count(); + return $count > 0 ? round($dbReviews->avg('rating'), 1) : 0.0; + } + + public function getReviewsCountAttribute() + { + if ($this->role !== 'employer') { + return 0; + } + return $this->employerReviews()->count(); + } + public function subscription() { return $this->hasOne(Subscription::class); diff --git a/app/Models/Worker.php b/app/Models/Worker.php index d83713c..bbf1926 100644 --- a/app/Models/Worker.php +++ b/app/Models/Worker.php @@ -59,6 +59,8 @@ class Worker extends Model 'document_expiry_days', 'document_expiry_status', 'visa_expiry_date', + 'rating', + 'reviews_count', ]; public function getPassportStatusAttribute() @@ -190,6 +192,25 @@ public function reviews() return $this->hasMany(Review::class); } + public function employerReviews() + { + return $this->hasMany(EmployerReview::class, 'worker_id'); + } + + public function getRatingAttribute() + { + // Prevent infinite recursion by not using relationships if we want to avoid eager load loops, + // but typically $this->reviews is safe as long as we don't eager load recursively. + $dbReviews = $this->reviews; + $count = $dbReviews->count(); + return $count > 0 ? round($dbReviews->avg('rating'), 1) : 0.0; + } + + public function getReviewsCountAttribute() + { + return $this->reviews()->count(); + } + public function profileViews() { return $this->hasMany(ProfileView::class); diff --git a/public/swagger.json b/public/swagger.json index 62a9169..6c53ef7 100644 --- a/public/swagger.json +++ b/public/swagger.json @@ -23,7 +23,7 @@ "Sponsor/Auth" ], "summary": "Register Sponsor Account", - "description": "Registers a new sponsor with basic information and uploads their organization/trade license. Sponsors have restricted access \u2014 dashboard and charity events only. No payment required.", + "description": "Registers a new sponsor with basic information and uploads their organization/trade license. Sponsors have restricted access — dashboard and charity events only. No payment required.", "security": [], "requestBody": { "required": true, @@ -51,7 +51,7 @@ "mobile": { "type": "string", "example": "+971501112233", - "description": "Mobile phone number \u00e2\u20ac\u201d must be unique. (REQUIRED)" + "description": "Mobile phone number — must be unique. (REQUIRED)" }, "password": { "type": "string", @@ -958,7 +958,7 @@ "Worker/Auth" ], "summary": "Register Worker Account (Step 1)", - "description": "Creates the worker account and returns a secure Bearer token.\n\nDocument upload is a **separate step** after registration:\n- `POST /workers/register/passport` \u2014 upload passport (OCR-processed)\n- `POST /workers/register/visa` \u2014 upload visa (OCR-processed)\n\nBoth document endpoints require the Bearer token returned here.", + "description": "Creates the worker account and returns a secure Bearer token.\n\nDocument upload is a **separate step** after registration:\n- `POST /workers/register/passport` — upload passport (OCR-processed)\n- `POST /workers/register/visa` — upload visa (OCR-processed)\n\nBoth document endpoints require the Bearer token returned here.", "security": [], "requestBody": { "required": true, @@ -1263,7 +1263,7 @@ } }, "422": { - "description": "Validation error \u2014 field validation failed or Passport OCR extraction failed.", + "description": "Validation error — field validation failed or Passport OCR extraction failed.", "content": { "application/json": { "schema": { @@ -1550,7 +1550,7 @@ }, "name": { "type": "string", - "example": "Hindi (\u0939\u093f\u0928\u094d\u0926\u0940)" + "example": "Hindi (हिन्दी)" } } } @@ -5966,7 +5966,7 @@ "name": { "type": "string", "example": "Ahmad" - }, + }, "company_name": { "type": "string", "example": "Ahmad Tech Ltd" @@ -5990,11 +5990,25 @@ "hired_at": { "type": "string", "format": "date-time", - "example": "2026-06-01T15:10:24.000000Z" + "example": "2026-06-01T15:10:24.000000Z" }, "hired_at_formatted": { "type": "string", "example": "Jun 01, 2026" + }, + "rating": { + "type": "number", + "example": 4.8 + }, + "reviews_count": { + "type": "integer", + "example": 3 + }, + "reviews": { + "type": "array", + "items": { + "type": "object" + } } } }, @@ -6038,6 +6052,20 @@ "hired_at_formatted": { "type": "string", "example": "Jun 01, 2026" + }, + "rating": { + "type": "number", + "example": 4.8 + }, + "reviews_count": { + "type": "integer", + "example": 3 + }, + "reviews": { + "type": "array", + "items": { + "type": "object" + } } } }, @@ -7283,7 +7311,7 @@ "Workers - Auth" ], "summary": "Forgot Password (Send OTP)", - "description": "Sends a 6-digit OTP to verify the worker's identity using their registered mobile number. Workers do not use email \u2014 mobile/phone is the primary identifier. OTP is valid for 10 minutes.", + "description": "Sends a 6-digit OTP to verify the worker's identity using their registered mobile number. Workers do not use email — mobile/phone is the primary identifier. OTP is valid for 10 minutes.", "operationId": "workerForgotPassword", "requestBody": { "required": true, @@ -7347,7 +7375,7 @@ } }, "422": { - "description": "Validation error \u2014 phone is required" + "description": "Validation error — phone is required" } } } @@ -7575,7 +7603,7 @@ } }, "422": { - "description": "Validation error \u2014 email or mobile required" + "description": "Validation error — email or mobile required" } } } @@ -8987,6 +9015,20 @@ "type": "integer", "example": 2 }, + "reviews": { + "type": "array", + "items": { + "type": "object" + }, + "description": "Reviews received by the worker from employers" + }, + "employer_reviews": { + "type": "array", + "items": { + "type": "object" + }, + "description": "Reviews submitted by the worker about employers" + }, "photo": { "type": "string", "nullable": true, diff --git a/resources/js/Pages/Employer/Shortlist.jsx b/resources/js/Pages/Employer/Shortlist.jsx index 8752546..0c8e792 100644 --- a/resources/js/Pages/Employer/Shortlist.jsx +++ b/resources/js/Pages/Employer/Shortlist.jsx @@ -20,8 +20,12 @@ import { HeartHandshake, CheckCircle, MapPin, - Calendar + Calendar, + Search, + RotateCcw, + ArrowUpDown } from 'lucide-react'; +import FilterDrawer, { FilterSection, FilterSelect, FilterInput, FilterChips, FilterCheckboxList } from '../../components/Employer/FilterDrawer'; const getLanguageFlag = (lang) => { const flags = { @@ -36,7 +40,7 @@ const getLanguageFlag = (lang) => { return flags[lang] || '🌐'; }; -export default function Shortlist({ shortlistedWorkers }) { +export default function Shortlist({ shortlistedWorkers, filtersMetadata = {} }) { const { t } = useTranslation(); const [workers, setWorkers] = useState(shortlistedWorkers || []); @@ -44,6 +48,90 @@ export default function Shortlist({ shortlistedWorkers }) { const [comparisonIds, setComparisonIds] = useState([]); const [previewWorker, setPreviewWorker] = useState(null); + // Basic Filters + const [searchQuery, setSearchQuery] = useState(''); + const [selectedProfession, setSelectedProfession] = useState('All Professions'); + const [selectedNationalities, setSelectedNationalities] = useState([]); + const [selectedGender, setSelectedGender] = useState('All Genders'); + const [selectedExperience, setSelectedExperience] = useState('All Experience'); + const [selectedReligion, setSelectedReligion] = useState('All Religions'); + const [maxSalary, setMaxSalary] = useState(5000); + + // Advanced Multi-Select Filters & Sorting + const [selectedLanguages, setSelectedLanguages] = useState([]); + const [selectedVisaStatuses, setSelectedVisaStatuses] = useState([]); + const [selectedSkills, setSelectedSkills] = useState([]); + const [selectedWorkTypes, setSelectedWorkTypes] = useState([]); + const [sortBy, setSortBy] = useState('default'); + + // Advanced Filters Toggle & Details + const [filterLocation, setFilterLocation] = useState('All'); + const [filterJobType, setFilterJobType] = useState('All'); + const [filterAccommodation, setFilterAccommodation] = useState('All'); + const [filterInCountry, setFilterInCountry] = useState('All'); + const [filterVisaType, setFilterVisaType] = useState('All'); + + // Drawer + const [drawerOpen, setDrawerOpen] = useState(false); + + const resetFilters = () => { + setSearchQuery(''); + setSelectedProfession('All Professions'); + setSelectedNationalities([]); + setSelectedGender('All Genders'); + setSelectedExperience('All Experience'); + setSelectedReligion('All Religions'); + setMaxSalary(5000); + setSelectedLanguages([]); + setSelectedVisaStatuses([]); + setSelectedSkills([]); + setSelectedWorkTypes([]); + setSortBy('default'); + setFilterLocation('All'); + setFilterJobType('All'); + setFilterAccommodation('All'); + setFilterInCountry('All'); + setFilterVisaType('All'); + }; + + const activeFilterCount = useMemo(() => { + let count = 0; + if (filterLocation !== 'All' && filterLocation.trim() !== '') count++; + if (filterJobType !== 'All') count++; + if (filterAccommodation !== 'All') count++; + if (selectedNationalities.length > 0) count++; + if (selectedGender !== 'All Genders') count++; + if (selectedProfession !== 'All Professions') count++; + if (filterInCountry !== 'All') count++; + if (filterInCountry === 'In Country' && filterVisaType !== 'All') count++; + if (selectedSkills.length > 0) count++; + if (selectedLanguages.length > 0) count++; + if (maxSalary < 5000) count++; + return count; + }, [filterLocation, filterJobType, filterAccommodation, selectedNationalities, selectedGender, selectedProfession, filterInCountry, filterVisaType, selectedSkills, selectedLanguages, maxSalary]); + + const activeFilterTags = useMemo(() => { + const tags = []; + if (filterLocation !== 'All' && filterLocation.trim()) tags.push({ key: 'loc', label: `📍 ${filterLocation}`, clear: () => setFilterLocation('All') }); + if (filterJobType !== 'All') tags.push({ key: 'job', label: `💼 ${filterJobType}`, clear: () => setFilterJobType('All') }); + if (filterAccommodation !== 'All') tags.push({ key: 'acc', label: `🏠 ${filterAccommodation.replace('_', '-')}`, clear: () => setFilterAccommodation('All') }); + if (selectedNationalities.length > 0) tags.push({ key: 'nat', label: `🌍 ${selectedNationalities.length} Nat`, clear: () => setSelectedNationalities([]) }); + if (selectedGender !== 'All Genders') tags.push({ key: 'gender', label: `🚻 ${selectedGender}`, clear: () => setSelectedGender('All Genders') }); + if (selectedProfession !== 'All Professions') tags.push({ key: 'profession', label: `🧑🔧 ${selectedProfession}`, clear: () => setSelectedProfession('All Professions') }); + if (filterInCountry !== 'All') tags.push({ key: 'country', label: `✈️ ${filterInCountry}`, clear: () => { setFilterInCountry('All'); setFilterVisaType('All'); } }); + if (filterInCountry === 'In Country' && filterVisaType !== 'All') tags.push({ key: 'visa', label: `🪪 ${filterVisaType}`, clear: () => setFilterVisaType('All') }); + if (selectedSkills.length > 0) tags.push({ key: 'skills', label: `🛠 ${selectedSkills.length} skill${selectedSkills.length > 1 ? 's' : ''}`, clear: () => setSelectedSkills([]) }); + if (selectedLanguages.length > 0) tags.push({ key: 'lang', label: `🗣 ${selectedLanguages.length} lang`, clear: () => setSelectedLanguages([]) }); + if (maxSalary < 5000) tags.push({ key: 'salary', label: `💰 < ${maxSalary} AED`, clear: () => setMaxSalary(5000) }); + return tags; + }, [filterLocation, filterJobType, filterAccommodation, selectedNationalities, selectedGender, selectedProfession, filterInCountry, filterVisaType, selectedSkills, selectedLanguages, maxSalary]); + + const toggleMultiSelect = (item, setSelectedList) => { + setSelectedList(prev => + prev.includes(item) ? prev.filter(x => x !== item) : [...prev, item] + ); + }; + const removeWorker = (id) => { // Optimistic UI state update setWorkers(workers.filter(w => w.id !== id)); @@ -67,6 +155,114 @@ export default function Shortlist({ shortlistedWorkers }) { } }; + // Filter and Sort Worker List + const filteredWorkers = useMemo(() => { + let list = [...workers]; + list = list.filter(worker => { + // Search Query + if (searchQuery.trim() !== '') { + const query = searchQuery.toLowerCase(); + const matchName = worker.name.toLowerCase().includes(query); + const matchBio = worker.bio?.toLowerCase().includes(query) || false; + const matchSkills = worker.skills?.some(s => s.toLowerCase().includes(query)) || false; + if (!matchName && !matchBio && !matchSkills) return false; + } + + // Dropdown filters + if (selectedProfession !== 'All Professions' && (!worker.main_profession || worker.main_profession.toLowerCase() !== selectedProfession.toLowerCase())) return false; + if (selectedNationalities.length > 0 && (!worker.nationality || !selectedNationalities.map(n => n.toLowerCase()).includes(worker.nationality.toLowerCase()))) return false; + if (selectedGender !== 'All Genders' && worker.gender && worker.gender.toLowerCase() !== selectedGender.toLowerCase()) return false; + if (selectedExperience !== 'All Experience' && worker.experience !== selectedExperience) return false; + if (selectedReligion !== 'All Religions' && worker.religion !== selectedReligion) return false; + if (maxSalary < 5000 && worker.salary > maxSalary) return false; + + // Multi-select filters + if (selectedLanguages.length > 0) { + const hasLang = worker.languages?.some(l => selectedLanguages.includes(l)); + if (!hasLang) return false; + } + if (selectedSkills.length > 0) { + const hasSkill = worker.skills?.some(s => selectedSkills.includes(s.toLowerCase())); + if (!hasSkill) return false; + } + if (selectedVisaStatuses.length > 0) { + if (!selectedVisaStatuses.includes(worker.visa_status)) return false; + } + if (selectedWorkTypes.length > 0 && !selectedWorkTypes.includes(worker.preferred_job_type)) return false; + + // Preferred Location + if (filterLocation !== 'All' && filterLocation.trim() !== '') { + const locKey = filterLocation.toLowerCase(); + const locationMapping = { + 'dubai': ['dubai', 'marina', 'barsha', 'nahda', 'jumeirah', 'deira', 'downtown', 'silicon', 'sports', 'motor', 'jlt', 'jbr', 'meydan', 'ranches', 'bay', 'mirdif', 'quoz'], + 'abu dhabi': ['abu dhabi', 'yas', 'khalifa', 'reem', 'saadiyat', 'raha', 'mussafah', 'zahiyah', 'karamah'], + 'oman': ['oman', 'muscat', 'salalah', 'sohar', 'nizwa', 'sur', 'ibri', 'rustaq'] + }; + const allowedKeywords = locationMapping[locKey] || [locKey]; + if (!worker.preferred_location) return false; + const wLoc = worker.preferred_location.toLowerCase(); + const matched = allowedKeywords.some(keyword => wLoc.includes(keyword)); + if (!matched) return false; + } + + // Job Type + if (filterJobType !== 'All') { + if (!worker.preferred_job_type || worker.preferred_job_type.toLowerCase() !== filterJobType.toLowerCase()) return false; + } + + // Accommodation + if (filterAccommodation !== 'All') { + if (!worker.live_in_out || worker.live_in_out.toLowerCase() !== filterAccommodation.toLowerCase()) return false; + } + + // In/Out Country + if (filterInCountry !== 'All') { + const wantInCountry = filterInCountry === 'In Country'; + const wVal = worker.in_country; + if ((wVal === true || wVal === '1' || wVal === 1) !== wantInCountry) return false; + } + + // Visa Type (if in country) + if (filterInCountry === 'In Country' && filterVisaType !== 'All') { + if (!worker.visa_status || !worker.visa_status.toLowerCase().includes(filterVisaType.toLowerCase())) return false; + } + + return true; + }); + + // Sorting + if (sortBy === 'salary_asc') { + list.sort((a, b) => a.salary - b.salary); + } else if (sortBy === 'salary_desc') { + list.sort((a, b) => b.salary - a.salary); + } else if (sortBy === 'rating') { + list.sort((a, b) => b.rating - a.rating); + } else if (sortBy === 'experience') { + list.sort((a, b) => b.age - a.age); // approximate experience by age + } + + return list; + }, [ + workers, + searchQuery, + selectedProfession, + selectedNationalities, + selectedGender, + selectedExperience, + selectedReligion, + maxSalary, + selectedLanguages, + selectedVisaStatuses, + selectedSkills, + selectedWorkTypes, + sortBy, + filterLocation, + filterJobType, + filterAccommodation, + filterInCountry, + filterVisaType + ]); + const comparedWorkers = useMemo(() => { return workers.filter(w => comparisonIds.includes(w.id)); }, [workers, comparisonIds]); @@ -90,12 +286,275 @@ export default function Shortlist({ shortlistedWorkers }) { - {workers.length > 0 ? ( -
Select "In Country" to enable this filter
+ )} ++ {t('no_matching_results_desc', 'Try clearing or modifying your filters to find your saved candidates.')} +
+ +diff --git a/tests/Feature/EmployerShortlistWebFilterTest.php b/tests/Feature/EmployerShortlistWebFilterTest.php new file mode 100644 index 0000000..cc64eeb --- /dev/null +++ b/tests/Feature/EmployerShortlistWebFilterTest.php @@ -0,0 +1,192 @@ +employer = User::create([ + 'name' => 'Test Employer', + 'email' => 'employer@example.com', + 'password' => bcrypt('password'), + 'role' => 'employer', + ]); + + $this->worker1 = Worker::create([ + 'name' => 'Worker Indian Dubai', + 'email' => 'worker1@example.com', + 'phone' => '+971501111111', + 'password' => bcrypt('password'), + 'nationality' => 'Indian', + 'verified' => true, + 'status' => 'active', + 'preferred_location' => 'Dubai', + 'preferred_job_type' => 'full-time', + 'live_in_out' => 'live_in', + 'in_country' => true, + 'visa_status' => 'Residence Visa', + 'main_profession' => 'Housemaid', + 'age' => 25, + 'salary' => 1500, + 'experience' => '3 Years', + 'religion' => 'Christian', + 'language' => 'EN', + 'availability' => 'Immediate', + 'bio' => 'Experienced helper', + 'gender' => 'female', + ]); + + WorkerDocument::create([ + 'worker_id' => $this->worker1->id, + 'type' => 'passport', + 'number' => 'P111111', + 'file_path' => 'passports/test1.jpg', + ]); + + $this->worker2 = Worker::create([ + 'name' => 'Worker Filipino Abu Dhabi', + 'email' => 'worker2@example.com', + 'phone' => '+971502222222', + 'password' => bcrypt('password'), + 'nationality' => 'Filipino', + 'verified' => true, + 'status' => 'active', + 'preferred_location' => 'Abu Dhabi', + 'preferred_job_type' => 'part-time', + 'live_in_out' => 'live_out', + 'in_country' => true, + 'visa_status' => 'Tourist Visa', + 'main_profession' => 'Nanny', + 'age' => 28, + 'salary' => 1800, + 'experience' => '2 Years', + 'religion' => 'Christian', + 'language' => 'TL', + 'availability' => 'Immediate', + 'bio' => 'Ready to work', + 'gender' => 'male', + ]); + + WorkerDocument::create([ + 'worker_id' => $this->worker2->id, + 'type' => 'passport', + 'number' => 'P222222', + 'file_path' => 'passports/test2.jpg', + ]); + + // Shortlist both workers for this employer + Shortlist::create([ + 'employer_id' => $this->employer->id, + 'worker_id' => $this->worker1->id, + ]); + + Shortlist::create([ + 'employer_id' => $this->employer->id, + 'worker_id' => $this->worker2->id, + ]); + } + + /** + * Test shortlist index page loads correct properties. + */ + public function test_employer_can_view_shortlist_with_metadata() + { + $response = $this->actingAs($this->employer) + ->withSession(['user' => $this->employer]) + ->get(route('employer.shortlist')); + + $response->assertStatus(200); + $response->assertSee('shortlistedWorkers'); + $response->assertSee('filtersMetadata'); + + // Check if both workers are returned when no filters are applied + $shortlisted = $response->viewData('page')['props']['shortlistedWorkers']; + $this->assertCount(2, $shortlisted); + } + + /** + * Test shortlist filtering by location. + */ + public function test_employer_shortlist_filtering_by_location() + { + $response = $this->actingAs($this->employer) + ->withSession(['user' => $this->employer]) + ->get(route('employer.shortlist', ['preferred_location' => 'Abu Dhabi'])); + + $response->assertStatus(200); + $shortlisted = $response->viewData('page')['props']['shortlistedWorkers']; + + $this->assertCount(1, $shortlisted); + $this->assertEquals('Worker Filipino Abu Dhabi', $shortlisted[0]['name']); + } + + /** + * Test shortlist filtering by nationality. + */ + public function test_employer_shortlist_filtering_by_nationality() + { + $response = $this->actingAs($this->employer) + ->withSession(['user' => $this->employer]) + ->get(route('employer.shortlist', ['nationality' => 'Indian'])); + + $response->assertStatus(200); + $shortlisted = $response->viewData('page')['props']['shortlistedWorkers']; + + $this->assertCount(1, $shortlisted); + $this->assertEquals('Worker Indian Dubai', $shortlisted[0]['name']); + } + + /** + * Test shortlist filtering by job type and accommodation. + */ + public function test_employer_shortlist_filtering_by_job_type_and_accommodation() + { + $response = $this->actingAs($this->employer) + ->withSession(['user' => $this->employer]) + ->get(route('employer.shortlist', [ + 'job_type' => 'part-time', + 'accommodation_type' => 'live_out' + ])); + + $response->assertStatus(200); + $shortlisted = $response->viewData('page')['props']['shortlistedWorkers']; + + $this->assertCount(1, $shortlisted); + $this->assertEquals('Worker Filipino Abu Dhabi', $shortlisted[0]['name']); + } + + /** + * Test shortlist filtering by visa status and gender. + */ + public function test_employer_shortlist_filtering_by_visa_status_and_gender() + { + $response = $this->actingAs($this->employer) + ->withSession(['user' => $this->employer]) + ->get(route('employer.shortlist', [ + 'visa_status' => 'Residence Visa', + 'gender' => 'female' + ])); + + $response->assertStatus(200); + $shortlisted = $response->viewData('page')['props']['shortlistedWorkers']; + + $this->assertCount(1, $shortlisted); + $this->assertEquals('Worker Indian Dubai', $shortlisted[0]['name']); + } +} diff --git a/tests/Feature/WorkerLoginReviewDataTest.php b/tests/Feature/WorkerLoginReviewDataTest.php new file mode 100644 index 0000000..86161e9 --- /dev/null +++ b/tests/Feature/WorkerLoginReviewDataTest.php @@ -0,0 +1,211 @@ +worker = Worker::create([ + 'name' => 'Rahul Sharma', + 'email' => 'rahul@example.com', + 'phone' => '+971501234567', + 'password' => bcrypt('password'), + 'nationality' => 'Indian', + 'age' => 25, + 'salary' => 1500, + 'availability' => 'Immediate', + 'experience' => 'Not Specified', + 'religion' => 'Not Specified', + 'bio' => 'Test', + 'verified' => true, + 'status' => 'active', + 'api_token' => 'worker-test-token', + ]); + + // Create passport document to prevent pending 404 + WorkerDocument::create([ + 'worker_id' => $this->worker->id, + 'type' => 'passport', + 'number' => '123456', + 'file_path' => 'passports/test.pdf', + ]); + + // 2. Create an employer + $this->employer = User::create([ + 'name' => 'Employer One', + 'email' => 'emp1@example.com', + 'password' => bcrypt('password'), + 'role' => 'employer', + 'api_token' => 'employer-test-token', + ]); + + EmployerProfile::create([ + 'user_id' => $this->employer->id, + 'company_name' => 'Ahmad Tech Ltd', + 'nationality' => 'UAE', + 'city' => 'Dubai', + 'phone' => '+971 50 123 4567', + 'address' => 'Dubai, UAE', + ]); + + // 3. Create job post + $this->job = JobPost::create([ + 'employer_id' => $this->employer->id, + 'title' => 'Test Job', + 'location' => 'Dubai', + 'salary' => 2500, + 'workers_needed' => 1, + 'job_type' => 'Full Time', + 'start_date' => now()->subMonths(4)->toDateString(), + 'description' => 'Test Job Description', + 'status' => 'active', + ]); + } + + public function test_worker_login_and_profile_returns_review_data() + { + // Create an Employer review of this Worker + Review::create([ + 'employer_id' => $this->employer->id, + 'worker_id' => $this->worker->id, + 'rating' => 4, + 'comment' => 'Good worker', + ]); + + // Create a Worker review of this Employer + EmployerReview::create([ + 'worker_id' => $this->worker->id, + 'employer_id' => $this->employer->id, + 'job_id' => $this->job->id, + 'rating' => 5, + 'title' => 'Nice place', + 'comment' => 'Great experience', + ]); + + // Act: Worker login + $response = $this->postJson('/api/workers/login', [ + 'phone' => '+971501234567', + 'password' => 'password' + ]); + + $response->assertStatus(200); + $workerData = $response->json('data.worker'); + + $this->assertEquals(4.0, $workerData['rating']); + $this->assertEquals(1, $workerData['reviews_count']); + $this->assertCount(1, $workerData['reviews']); + $this->assertEquals('Good worker', $workerData['reviews'][0]['comment']); + $this->assertEquals('Employer One', $workerData['reviews'][0]['employer']['name']); + + $this->assertCount(1, $workerData['employer_reviews']); + $this->assertEquals('Great experience', $workerData['employer_reviews'][0]['comment']); + $this->assertEquals('Employer One', $workerData['employer_reviews'][0]['employer']['name']); + + // Act: Get worker profile + $token = $response->json('data.token'); + $profileResponse = $this->withHeaders([ + 'Authorization' => 'Bearer ' . $token, + ])->getJson('/api/workers/profile'); + + $profileResponse->assertStatus(200); + $profileWorker = $profileResponse->json('data.worker'); + $this->assertEquals(4.0, $profileWorker['rating']); + $this->assertEquals(1, $profileWorker['reviews_count']); + } + + public function test_worker_dashboard_and_employers_list_includes_employer_review_stats() + { + // Hired job offer to establish relationship + JobOffer::create([ + 'employer_id' => $this->employer->id, + 'worker_id' => $this->worker->id, + 'work_date' => now()->subDays(10), + 'location' => 'Dubai', + 'salary' => 2500, + 'status' => 'accepted', + 'notes' => 'Current Active Job', + ]); + + // Submit worker review of employer + EmployerReview::create([ + 'worker_id' => $this->worker->id, + 'employer_id' => $this->employer->id, + 'job_id' => $this->job->id, + 'rating' => 5, + 'title' => 'Fantastic', + 'comment' => 'Very good', + ]); + + // Act: Get dashboard + $dashboardResponse = $this->withHeaders([ + 'Authorization' => 'Bearer worker-test-token', + ])->getJson('/api/workers/dashboard'); + + $dashboardResponse->assertStatus(200); + $currentEmp = $dashboardResponse->json('data.current_employer'); + $this->assertEquals(5.0, $currentEmp['rating']); + $this->assertEquals(1, $currentEmp['reviews_count']); + $this->assertCount(1, $currentEmp['reviews']); + $this->assertEquals('Very good', $currentEmp['reviews'][0]['comment']); + + // Act: Get employers list + $employersResponse = $this->withHeaders([ + 'Authorization' => 'Bearer worker-test-token', + ])->getJson('/api/workers/employers'); + + $employersResponse->assertStatus(200); + $listEmp = $employersResponse->json('data.employers.0.employer'); + $this->assertEquals(5.0, $listEmp['rating']); + $this->assertEquals(1, $listEmp['reviews_count']); + $this->assertCount(1, $listEmp['reviews']); + } + + public function test_employer_profile_returns_review_data() + { + // Submit review of employer + EmployerReview::create([ + 'worker_id' => $this->worker->id, + 'employer_id' => $this->employer->id, + 'job_id' => $this->job->id, + 'rating' => 5, + 'title' => 'Nice place', + 'comment' => 'Great experience', + ]); + + // Act: Get employer profile + $response = $this->withHeaders([ + 'Authorization' => 'Bearer employer-test-token', + ])->getJson('/api/employers/profile'); + + $response->assertStatus(200); + $profile = $response->json('data.profile'); + + $this->assertEquals(5.0, $profile['rating']); + $this->assertEquals(1, $profile['reviews_count']); + $this->assertCount(1, $profile['reviews']); + $this->assertEquals('Great experience', $profile['reviews'][0]['comment']); + $this->assertEquals('Rahul Sharma', $profile['reviews'][0]['worker']['name']); + } +}