diff --git a/.env.example b/.env.example index c0660ea..566bca8 100644 --- a/.env.example +++ b/.env.example @@ -62,4 +62,6 @@ AWS_DEFAULT_REGION=us-east-1 AWS_BUCKET= AWS_USE_PATH_STYLE_ENDPOINT=false +OCR_API_URL=http://192.168.0.220:5000/passport + VITE_APP_NAME="${APP_NAME}" diff --git a/app/Http/Controllers/Admin/EmployerController.php b/app/Http/Controllers/Admin/EmployerController.php new file mode 100644 index 0000000..2482d1c --- /dev/null +++ b/app/Http/Controllers/Admin/EmployerController.php @@ -0,0 +1,261 @@ +with(['employerProfile', 'sponsor']); + + // Search + if ($request->filled('search')) { + $search = $request->input('search'); + $query->where(function($q) use ($search) { + $q->where('name', 'like', "%{$search}%") + ->orWhere('email', 'like', "%{$search}%") + ->orWhereHas('employerProfile', function($qp) use ($search) { + $qp->where('company_name', 'like', "%{$search}%") + ->orWhere('phone', 'like', "%{$search}%"); + }); + }); + } + + // Status filter (on sponsor status) + if ($request->filled('status') && $request->input('status') !== 'All') { + $status = strtolower($request->input('status')); + $query->whereHas('sponsor', function($qs) use ($status) { + $qs->where('status', $status); + }); + } + + // Location filter + if ($request->filled('location') && $request->input('location') !== 'All') { + $location = $request->input('location'); + $query->whereHas('employerProfile', function($qp) use ($location) { + $qp->where('country', $location); + }); + } + + // Sorting + $sortField = $request->input('sort_field', 'created_at'); + $sortOrder = $request->input('sort_order', 'desc'); + if (in_array($sortField, ['name', 'email', 'created_at'])) { + $query->orderBy($sortField, $sortOrder); + } else { + $query->orderBy('created_at', 'desc'); + } + + $employers = $query->paginate(10)->withQueryString(); + + // Transform results to format status correctly from sponsor + $employers->getCollection()->transform(function($emp) { + $emp->status = $emp->sponsor ? $emp->sponsor->status : 'active'; + return $emp; + }); + + return Inertia::render('Admin/Employers/Index', [ + 'employers' => $employers, + 'filters' => $request->only(['search', 'status', 'location', 'sort_field', 'sort_order']) + ]); + } + + /** + * Export employers as CSV. + */ + public function export(Request $request) + { + $query = User::where('role', 'employer')->with(['employerProfile', 'sponsor']); + + // Search + if ($request->filled('search')) { + $search = $request->input('search'); + $query->where(function($q) use ($search) { + $q->where('name', 'like', "%{$search}%") + ->orWhere('email', 'like', "%{$search}%") + ->orWhereHas('employerProfile', function($qp) use ($search) { + $qp->where('company_name', 'like', "%{$search}%") + ->orWhere('phone', 'like', "%{$search}%"); + }); + }); + } + + // Status filter + if ($request->filled('status') && $request->input('status') !== 'All') { + $status = strtolower($request->input('status')); + $query->whereHas('sponsor', function($qs) use ($status) { + $qs->where('status', $status); + }); + } + + $employers = $query->get(); + + $headers = [ + "Content-type" => "text/csv", + "Content-Disposition" => "attachment; filename=employers_export_" . date('Ymd_His') . ".csv", + "Pragma" => "no-cache", + "Cache-Control" => "must-revalidate, post-check=0, pre-check=0", + "Expires" => "0" + ]; + + $columns = ['ID', 'Name', 'Email', 'Company Name', 'Phone', 'Country', 'Status', 'Subscription Status', 'Created At']; + + $callback = function() use ($employers, $columns) { + $file = fopen('php://output', 'w'); + fputcsv($file, $columns); + + foreach ($employers as $emp) { + fputcsv($file, [ + $emp->id, + $emp->name, + $emp->email, + $emp->employerProfile->company_name ?? 'N/A', + $emp->employerProfile->phone ?? 'N/A', + $emp->employerProfile->country ?? 'N/A', + $emp->sponsor ? $emp->sponsor->status : 'active', + $emp->subscription_status ?? 'none', + $emp->created_at ? $emp->created_at->toDateTimeString() : 'N/A', + ]); + } + + fclose($file); + }; + + return response()->stream($callback, 200, $headers); + } + + /** + * Verify / approve employer profile. + */ + public function verify($id) + { + $user = User::where('role', 'employer')->findOrFail($id); + if ($user->employerProfile) { + $user->employerProfile->update([ + 'verification_status' => 'approved' + ]); + } + + $sponsor = Sponsor::where('email', $user->email)->first(); + if ($sponsor) { + $sponsor->update([ + 'is_verified' => true, + 'status' => 'active' + ]); + } + + return back()->with('success', "Employer '{$user->name}' profile verified and activated successfully."); + } + + /** + * Suspend an employer. + */ + public function suspend($id) + { + $user = User::where('role', 'employer')->findOrFail($id); + + $sponsor = Sponsor::where('email', $user->email)->first(); + if ($sponsor) { + $sponsor->update(['status' => 'suspended']); + } + + return back()->with('success', "Employer '{$user->name}' suspended successfully."); + } + + /** + * Activate a suspended/inactive employer. + */ + public function activate($id) + { + $user = User::where('role', 'employer')->findOrFail($id); + + $sponsor = Sponsor::where('email', $user->email)->first(); + if ($sponsor) { + $sponsor->update(['status' => 'active']); + } + + return back()->with('success', "Employer '{$user->name}' activated successfully."); + } + + /** + * Delete an employer. + */ + public function delete($id) + { + $user = User::where('role', 'employer')->findOrFail($id); + $name = $user->name; + + $sponsor = Sponsor::where('email', $user->email)->first(); + if ($sponsor) { + $sponsor->delete(); + } + + if ($user->employerProfile) { + $user->employerProfile->delete(); + } + $user->delete(); + + return back()->with('success', "Employer '{$name}' deleted successfully."); + } + + /** + * Update an employer's fields. + */ + public function update(Request $request, $id) + { + $user = User::where('role', 'employer')->findOrFail($id); + $validated = $request->validate([ + 'name' => 'required|string|max:255', + 'email' => 'required|email|unique:users,email,' . $user->id, + 'company_name' => 'nullable|string|max:255', + 'phone' => 'nullable|string', + 'country' => 'nullable|string', + 'subscription_status' => 'nullable|string', + ]); + + $user->update([ + 'name' => $validated['name'], + 'email' => $validated['email'], + 'subscription_status' => $validated['subscription_status'] ?? $user->subscription_status, + ]); + + if ($user->employerProfile) { + $user->employerProfile->update([ + 'company_name' => $validated['company_name'] ?? $user->employerProfile->company_name, + 'phone' => $validated['phone'] ?? $user->employerProfile->phone, + 'country' => $validated['country'] ?? $user->employerProfile->country, + ]); + } else { + EmployerProfile::create([ + 'user_id' => $user->id, + 'company_name' => $validated['company_name'], + 'phone' => $validated['phone'], + 'country' => $validated['country'], + ]); + } + + $sponsor = Sponsor::where('email', $user->email)->first(); + if ($sponsor) { + $sponsor->update([ + 'full_name' => $validated['name'], + 'email' => $validated['email'], + 'mobile' => $validated['phone'] ?? $sponsor->mobile, + 'organization_name' => $validated['company_name'] ?? $sponsor->organization_name, + 'city' => $validated['country'] ?? $sponsor->city, + ]); + } + + return back()->with('success', "Employer details saved successfully."); + } +} diff --git a/app/Http/Controllers/Admin/SponsorController.php b/app/Http/Controllers/Admin/SponsorController.php index 1546358..957f537 100644 --- a/app/Http/Controllers/Admin/SponsorController.php +++ b/app/Http/Controllers/Admin/SponsorController.php @@ -22,6 +22,7 @@ public function index(Request $request) $search = $request->input('search'); $query->where(function($q) use ($search) { $q->where('full_name', 'like', "%{$search}%") + ->orWhere('organization_name', 'like', "%{$search}%") ->orWhere('email', 'like', "%{$search}%") ->orWhere('mobile', 'like', "%{$search}%"); }); @@ -32,15 +33,35 @@ public function index(Request $request) $query->where('status', strtolower($request->input('status'))); } - // Server-side Location/City filter - if ($request->filled('location') && $request->input('location') !== 'All') { - $query->where('city', $request->input('location')); + + + // Verification filter + if ($request->filled('verification') && $request->input('verification') !== 'All') { + $isVerified = $request->input('verification') === 'Verified'; + $query->where('is_verified', $isVerified); + } + + // License filter + if ($request->filled('license') && $request->input('license') !== 'All') { + $license = $request->input('license'); + if ($license === 'Expired') { + $query->where('license_expiry', '<', now()); + } elseif ($license === 'Valid') { + $query->where('license_expiry', '>=', now()); + } elseif ($license === 'Expiring Soon') { + $query->whereBetween('license_expiry', [now(), now()->addDays(30)]); + } + } + + // Nationality filter + if ($request->filled('nationality') && $request->input('nationality') !== 'All') { + $query->where('nationality', $request->input('nationality')); } // Server-side Sorting $sortField = $request->input('sort_field', 'created_at'); $sortOrder = $request->input('sort_order', 'desc'); - if (in_array($sortField, ['full_name', 'email', 'city', 'status', 'created_at'])) { + if (in_array($sortField, ['full_name', 'organization_name', 'email', 'city', 'status', 'created_at'])) { $query->orderBy($sortField, $sortOrder); } else { $query->orderBy('created_at', 'desc'); @@ -49,9 +70,41 @@ public function index(Request $request) // Paginate $sponsors = $query->paginate(10)->withQueryString(); - return Inertia::render('Admin/Employers/Index', [ + // Get distinct nationalities + $nationalities = [ + 'Afghan', 'Albanian', 'Algerian', 'American', 'Andorran', 'Angolan', 'Argentinian', 'Armenian', 'Australian', 'Austrian', + 'Azerbaijani', 'Bahamian', 'Bahraini', 'Bangladeshi', 'Barbadian', 'Belarusian', 'Belgian', 'Belizean', 'Beninese', 'Bhutanese', + 'Bolivian', 'Bosnian', 'Brazilian', 'British', 'Bruneian', 'Bulgarian', 'Burkinabe', 'Burmese', 'Burundian', 'Cambodian', + 'Cameroonian', 'Canadian', 'Cape Verdean', 'Central African', 'Chadian', 'Chilean', 'Chinese', 'Colombian', 'Comoran', 'Congolese', + 'Costa Rican', 'Croatian', 'Cuban', 'Cypriot', 'Czech', 'Danish', 'Djiboutian', 'Dominican', 'Dutch', 'East Timorese', + 'Ecuadorian', 'Egyptian', 'Emirati', 'Equatorial Guinean', 'Eritrean', 'Estonian', 'Ethiopian', 'Fijian', 'Filipino', 'Finnish', + 'French', 'Gabonese', 'Gambian', 'Georgian', 'German', 'Ghanaian', 'Greek', 'Grenadian', 'Guatemalan', 'Guinean', + 'Guyanese', 'Haitian', 'Honduran', 'Hungarian', 'Icelandic', 'Indian', 'Indonesian', 'Iranian', 'Iraqi', 'Irish', + 'Italian', 'Ivorian', 'Jamaican', 'Japanese', 'Jordanian', 'Kazakh', 'Kenyan', 'Kuwaiti', 'Kyrgyz', 'Laotian', + 'Latvian', 'Lebanese', 'Liberian', 'Libyan', 'Liechtensteiner', 'Lithuanian', 'Luxembourger', 'Macedonian', 'Malagasy', 'Malawian', + 'Malaysian', 'Maldivian', 'Malian', 'Maltese', 'Mauritanian', 'Mauritian', 'Mexican', 'Micronesian', 'Moldovan', 'Monacan', + 'Mongolian', 'Montenegrin', 'Moroccan', 'Mozambican', 'Namibian', 'Nauruan', 'Nepalese', 'New Zealander', 'Nicaraguan', 'Nigerian', + 'Nigerien', 'North Korean', 'Norwegian', 'Omani', 'Pakistani', 'Palauans', 'Palestinian', 'Panamanian', 'Papua New Guinean', 'Paraguayan', + 'Peruvian', 'Polish', 'Portuguese', 'Qatari', 'Romanian', 'Russian', 'Rwandan', 'Saint Lucian', 'Salvadoran', 'Samoan', + 'San Marinese', 'Sao Tomean', 'Saudi Arabian', 'Senegalese', 'Serbian', 'Seychellois', 'Sierra Leonean', 'Singaporean', 'Slovak', 'Slovenian', + 'Solomon Islander', 'Somali', 'South African', 'South Korean', 'Spanish', 'Sri Lankan', 'Sudanese', 'Surinamese', 'Swazi', 'Swedish', + 'Swiss', 'Syrian', 'Taiwanese', 'Tajik', 'Tanzanian', 'Thai', 'Togolese', 'Tongan', 'Trinidadian', 'Tunisian', + 'Turkish', 'Turkmen', 'Tuvaluan', 'Ugandan', 'Ukrainian', 'Uruguayan', 'Uzbek', 'Vanuatuan', 'Venezuelan', 'Vietnamese', + 'Western Samoan', 'Yemeni', 'Zambian', 'Zimbabwean' + ]; + + // Global stats counts (removing Active plans / Pending approval, replacing with Total, Verified, Suspended) + $stats = [ + 'total' => Sponsor::count(), + 'verified' => Sponsor::where('is_verified', true)->count(), + 'suspended' => Sponsor::where('status', 'suspended')->count(), + ]; + + return Inertia::render('Admin/CharityOrganizations/Index', [ 'sponsors' => $sponsors, - 'filters' => $request->only(['search', 'status', 'location', 'sort_field', 'sort_order']) + 'filters' => $request->only(['search', 'status', 'verification', 'license', 'nationality', 'sort_field', 'sort_order']), + 'nationalities' => $nationalities, + 'stats' => $stats ]); } @@ -67,6 +120,7 @@ public function export(Request $request) $search = $request->input('search'); $query->where(function($q) use ($search) { $q->where('full_name', 'like', "%{$search}%") + ->orWhere('organization_name', 'like', "%{$search}%") ->orWhere('email', 'like', "%{$search}%") ->orWhere('mobile', 'like', "%{$search}%"); }); @@ -77,15 +131,35 @@ public function export(Request $request) $query->where('status', strtolower($request->input('status'))); } - // Server-side Location/City filter - if ($request->filled('location') && $request->input('location') !== 'All') { - $query->where('city', $request->input('location')); + + + // Verification filter + if ($request->filled('verification') && $request->input('verification') !== 'All') { + $isVerified = $request->input('verification') === 'Verified'; + $query->where('is_verified', $isVerified); + } + + // License filter + if ($request->filled('license') && $request->input('license') !== 'All') { + $license = $request->input('license'); + if ($license === 'Expired') { + $query->where('license_expiry', '<', now()); + } elseif ($license === 'Valid') { + $query->where('license_expiry', '>=', now()); + } elseif ($license === 'Expiring Soon') { + $query->whereBetween('license_expiry', [now(), now()->addDays(30)]); + } + } + + // Nationality filter + if ($request->filled('nationality') && $request->input('nationality') !== 'All') { + $query->where('nationality', $request->input('nationality')); } // Server-side Sorting $sortField = $request->input('sort_field', 'created_at'); $sortOrder = $request->input('sort_order', 'desc'); - if (in_array($sortField, ['full_name', 'email', 'city', 'status', 'created_at'])) { + if (in_array($sortField, ['full_name', 'organization_name', 'email', 'city', 'status', 'created_at'])) { $query->orderBy($sortField, $sortOrder); } else { $query->orderBy('created_at', 'desc'); @@ -95,13 +169,13 @@ public function export(Request $request) $headers = [ "Content-type" => "text/csv", - "Content-Disposition" => "attachment; filename=sponsors_export_" . date('Ymd_His') . ".csv", + "Content-Disposition" => "attachment; filename=charity_organizations_export_" . date('Ymd_His') . ".csv", "Pragma" => "no-cache", "Cache-Control" => "must-revalidate, post-check=0, pre-check=0", "Expires" => "0" ]; - $columns = ['ID', 'Full Name', 'Email', 'Mobile', 'City', 'Nationality', 'Address', 'Status', 'OTP Verified', 'Verified At', 'Subscription Plan', 'Subscription Status', 'Subscription Expiry', 'Created At']; + $columns = ['ID', 'Organization Name', 'Full Name', 'Email', 'Mobile', 'City', 'Nationality', 'Address', 'Status', 'Verified', 'License Expiry', 'Created At']; $callback = function() use ($sponsors, $columns) { $file = fopen('php://output', 'w'); @@ -110,6 +184,7 @@ public function export(Request $request) foreach ($sponsors as $sponsor) { fputcsv($file, [ $sponsor->id, + $sponsor->organization_name ?? 'N/A', $sponsor->full_name, $sponsor->email, $sponsor->mobile, @@ -118,10 +193,7 @@ public function export(Request $request) $sponsor->address ?? 'N/A', $sponsor->status, $sponsor->is_verified ? 'Yes' : 'No', - $sponsor->otp_verified_at ? $sponsor->otp_verified_at->toDateTimeString() : 'N/A', - $sponsor->subscription_plan ?? 'None', - $sponsor->subscription_status ?? 'None', - $sponsor->subscription_end_date ? $sponsor->subscription_end_date->toDateString() : 'N/A', + $sponsor->license_expiry ? $sponsor->license_expiry->toDateString() : 'N/A', $sponsor->created_at ? $sponsor->created_at->toDateTimeString() : 'N/A', ]); } @@ -193,6 +265,7 @@ public function update(Request $request, $id) $sponsor = Sponsor::findOrFail($id); $validated = $request->validate([ 'full_name' => 'required|string|max:255', + 'organization_name' => 'nullable|string|max:255', 'email' => 'required|email|unique:sponsors,email,' . $sponsor->id, 'mobile' => 'required|string', 'city' => 'required|string', diff --git a/app/Http/Controllers/Api/EmployerAuthController.php b/app/Http/Controllers/Api/EmployerAuthController.php index c464a56..57e22d2 100644 --- a/app/Http/Controllers/Api/EmployerAuthController.php +++ b/app/Http/Controllers/Api/EmployerAuthController.php @@ -188,9 +188,12 @@ public function register(Request $request) { $validator = Validator::make($request->all(), [ 'name' => 'required|string|max:255', - 'email' => 'required|string|email|max:255|unique:users,email', - 'phone' => 'required|string|max:50', + 'email' => 'required|string|email|max:255|unique:users,email|unique:sponsors,email', + 'phone' => 'required|string|max:50|unique:employer_profiles,phone|unique:sponsors,mobile', 'fcm_token' => 'nullable|string|max:255', + ], [ + 'email.unique' => 'This email address is already registered.', + 'phone.unique' => 'This mobile number is already registered.', ]); if ($validator->fails()) { @@ -409,23 +412,11 @@ public function uploadEmiratesId(Request $request) } $file = $request->file('emirates_id_file'); - $destinationPath = public_path('uploads/documents'); - if (!file_exists($destinationPath)) { - mkdir($destinationPath, 0755, true); - } - $fileName = time() . '_employer_eid_' . preg_replace('/[^a-zA-Z0-9_.-]/', '', $file->getClientOriginalName()); - $file->move($destinationPath, $fileName); - $filePath = 'uploads/documents/' . $fileName; - - // OCR Simulated extraction - $extractedIdNumber = '784-' . rand(1970, 2005) . '-' . rand(1000000, 9999999) . '-' . rand(1, 9); - $extractedExpiry = now()->addYears(rand(2, 4))->toDateString(); - - // We are not storing this document, just extract data and delete this file - if (file_exists(public_path($filePath))) { - unlink(public_path($filePath)); - } + // Extract data using OcrDocumentService without storing the file on disk + $ocrResult = \App\Services\OcrDocumentService::extractData($file); + $extractedIdNumber = $ocrResult['document_number']; + $extractedExpiry = $ocrResult['expiry_date']; // Update or create EmployerProfile $profile = EmployerProfile::where('user_id', $user->id)->first(); diff --git a/app/Http/Controllers/Api/EmployerProfileController.php b/app/Http/Controllers/Api/EmployerProfileController.php index 84a72d2..92230df 100644 --- a/app/Http/Controllers/Api/EmployerProfileController.php +++ b/app/Http/Controllers/Api/EmployerProfileController.php @@ -87,7 +87,13 @@ public function updateProfile(Request $request) 'unique:users,email,' . $employer->id, $sponsor ? 'unique:sponsors,email,' . $sponsor->id : 'unique:sponsors,email', ], - 'phone' => 'required|string|max:255', + 'phone' => [ + 'required', + 'string', + 'max:255', + 'unique:employer_profiles,phone,' . ($employer->employerProfile ? $employer->employerProfile->id : 'NULL'), + $sponsor ? 'unique:sponsors,mobile,' . $sponsor->id : 'unique:sponsors,mobile', + ], 'company_name' => 'required|string|max:255', 'language' => 'required|string|in:English,Arabic,english,arabic', 'notifications' => 'required|boolean', diff --git a/app/Http/Controllers/Api/SponsorAuthController.php b/app/Http/Controllers/Api/SponsorAuthController.php index 0efebd1..3fd0c66 100644 --- a/app/Http/Controllers/Api/SponsorAuthController.php +++ b/app/Http/Controllers/Api/SponsorAuthController.php @@ -24,12 +24,12 @@ public function register(Request $request) { $validator = Validator::make($request->all(), [ 'full_name' => 'required|string|max:255', - 'mobile' => 'required|string|max:50|unique:sponsors,mobile', + 'mobile' => 'required|string|max:50|unique:sponsors,mobile|unique:employer_profiles,phone', 'password' => 'required|string|min:6', 'license_file' => 'required|file|mimes:jpeg,png,jpg,pdf|max:10240', 'emirates_id_file' => 'required|file|mimes:jpeg,png,jpg,pdf|max:10240', 'organization_name' => 'required|string|max:255', - 'email' => 'required|email|max:255|unique:sponsors,email', + 'email' => 'required|email|max:255|unique:sponsors,email|unique:users,email', 'nationality' => 'required|string|max:100', 'city' => 'required|string|max:100', 'address' => 'required|string|max:255', @@ -58,29 +58,19 @@ public function register(Request $request) $email = "sponsor.{$mobileClean}." . rand(10, 99) . "@migrant.ae"; } - // Store files - $destinationPath = public_path('uploads/licenses'); - if (!file_exists($destinationPath)) { - mkdir($destinationPath, 0755, true); - } - $licenseFile = $request->file('license_file'); - $licenseFileName = time() . '_license_' . preg_replace('/[^a-zA-Z0-9_.-]/', '', $licenseFile->getClientOriginalName()); - $licenseFile->move($destinationPath, $licenseFileName); - $licensePath = 'uploads/licenses/' . $licenseFileName; + $ocrLicense = \App\Services\OcrDocumentService::extractData($licenseFile); + $licenseExpiry = $request->license_expiry ?? $ocrLicense['expiry_date']; $emiratesIdPath = null; if ($request->hasFile('emirates_id_file')) { $emiratesIdFile = $request->file('emirates_id_file'); - $emiratesIdFileName = time() . '_emirates_id_' . preg_replace('/[^a-zA-Z0-9_.-]/', '', $emiratesIdFile->getClientOriginalName()); - $emiratesIdFile->move($destinationPath, $emiratesIdFileName); - $emiratesIdPath = 'uploads/licenses/' . $emiratesIdFileName; + \App\Services\OcrDocumentService::extractData($emiratesIdFile); } + $licensePath = null; $apiToken = Str::random(80); - $licenseExpiry = $request->license_expiry ?? now()->addYears(3)->toDateString(); - $sponsor = DB::transaction(function () use ($request, $email, $licensePath, $emiratesIdPath, $apiToken, $licenseExpiry) { return Sponsor::create([ 'full_name' => $request->full_name, diff --git a/app/Http/Controllers/Api/WorkerAuthController.php b/app/Http/Controllers/Api/WorkerAuthController.php index 5725361..d1a14da 100644 --- a/app/Http/Controllers/Api/WorkerAuthController.php +++ b/app/Http/Controllers/Api/WorkerAuthController.php @@ -57,11 +57,13 @@ public function skills() public function sendOtp(Request $request) { $validator = Validator::make($request->all(), [ - 'phone' => 'required_without:email|nullable|string|max:50', - 'email' => 'required_without:phone|nullable|email|max:255', + 'phone' => 'required_without:email|nullable|string|max:50|unique:workers,phone', + 'email' => 'required_without:phone|nullable|email|max:255|unique:workers,email', ], [ 'phone.required_without' => 'Please provide a mobile number or email address.', + 'phone.unique' => 'This mobile number is already registered.', 'email.required_without' => 'Please provide a mobile number or email address.', + 'email.unique' => 'This email address is already registered.', ]); if ($validator->fails()) { @@ -344,32 +346,55 @@ public function register(Request $request) } } - $destinationPath = public_path('uploads/documents'); - if (!file_exists($destinationPath)) { - mkdir($destinationPath, 0755, true); + $passportData = [ + 'number' => 'P' . rand(100000, 999999), + 'expiry_date' => now()->addYears(8)->toDateString(), + 'issue_date' => now()->subYears(2)->toDateString(), + ]; + + if ($request->hasFile('passport_file')) { + $file = $request->file('passport_file'); + $ocrRes = \App\Services\OcrDocumentService::extractData($file); + if (!empty($ocrRes['document_number'])) { + $passportData['number'] = $ocrRes['document_number']; + } + if (!empty($ocrRes['expiry_date'])) { + $passportData['expiry_date'] = $ocrRes['expiry_date']; + } + if (!empty($ocrRes['expiry_date'])) { + $passportData['issue_date'] = date('Y-m-d', strtotime($ocrRes['expiry_date'] . ' - 5 years')); + } + } + + $visaData = [ + 'number' => 'V' . rand(100000, 999999), + 'expiry_date' => now()->addYears(2)->toDateString(), + 'issue_date' => now()->subYear()->toDateString(), + 'ocr_accuracy' => 0.0, + ]; + + if ($request->hasFile('visa_file')) { + $file = $request->file('visa_file'); + $ocrRes = \App\Services\OcrDocumentService::extractData($file); + if (!empty($ocrRes['document_number'])) { + $visaData['number'] = $ocrRes['document_number']; + } + if (!empty($ocrRes['expiry_date'])) { + $visaData['expiry_date'] = $ocrRes['expiry_date']; + } + if (!empty($ocrRes['expiry_date'])) { + $visaData['issue_date'] = date('Y-m-d', strtotime($ocrRes['expiry_date'] . ' - 2 years')); + } + $visaData['ocr_accuracy'] = 98.5; } $passportPath = null; $visaPath = null; - if ($request->hasFile('passport_file')) { - $file = $request->file('passport_file'); - $fileName = time() . '_passport_' . preg_replace('/[^a-zA-Z0-9_.-]/', '', $file->getClientOriginalName()); - $file->move($destinationPath, $fileName); - $passportPath = 'uploads/documents/' . $fileName; - } - - if ($request->hasFile('visa_file')) { - $file = $request->file('visa_file'); - $fileName = time() . '_visa_' . preg_replace('/[^a-zA-Z0-9_.-]/', '', $file->getClientOriginalName()); - $file->move($destinationPath, $fileName); - $visaPath = 'uploads/documents/' . $fileName; - } - $inCountry = filter_var($request->input('in_country', true), FILTER_VALIDATE_BOOLEAN); $apiToken = Str::random(80); - $result = DB::transaction(function () use ($request, $email, $skillsArray, $passportPath, $visaPath, $apiToken, $inCountry) { + $result = DB::transaction(function () use ($request, $email, $skillsArray, $passportPath, $visaPath, $apiToken, $inCountry, $passportData, $visaData) { $worker = Worker::create([ 'name' => $request->name, 'email' => $email, @@ -404,19 +429,19 @@ public function register(Request $request) $worker->documents()->create([ 'type' => 'passport', - 'number' => 'P' . rand(100000, 999999), - 'issue_date' => now()->subYears(2)->toDateString(), - 'expiry_date' => now()->addYears(8)->toDateString(), + 'number' => $passportData['number'], + 'issue_date' => $passportData['issue_date'], + 'expiry_date' => $passportData['expiry_date'], 'ocr_accuracy'=> 98.5, 'file_path' => $passportPath, ]); $worker->documents()->create([ 'type' => 'visa', - 'number' => 'V' . rand(100000, 999999), - 'issue_date' => now()->subYear()->toDateString(), - 'expiry_date' => now()->addYears(2)->toDateString(), - 'ocr_accuracy'=> $visaPath ? 98.5 : 0.0, + 'number' => $visaData['number'], + 'issue_date' => $visaData['issue_date'], + 'expiry_date' => $visaData['expiry_date'], + 'ocr_accuracy'=> $visaData['ocr_accuracy'], 'file_path' => $visaPath, ]); diff --git a/app/Http/Controllers/Employer/EmployerAuthController.php b/app/Http/Controllers/Employer/EmployerAuthController.php index 5495ede..87c6b29 100644 --- a/app/Http/Controllers/Employer/EmployerAuthController.php +++ b/app/Http/Controllers/Employer/EmployerAuthController.php @@ -90,7 +90,7 @@ public function register(Request $request) ]); // 2. Email uniqueness check (Check DB, return 409 if duplicate) - if (User::where('email', $request->email)->exists()) { + if (User::where('email', $request->email)->exists() || \App\Models\Sponsor::where('email', $request->email)->exists()) { return response()->json([ 'errors' => [ 'email' => 'This email address is already registered. Please login or use a different email.' @@ -98,6 +98,16 @@ public function register(Request $request) ], 409); } + // 2b. Phone uniqueness check + $phoneClean = preg_replace('/[^0-9]/', '', $request->phone); + if (\App\Models\EmployerProfile::where('phone', 'like', '%' . $phoneClean . '%')->exists() || \App\Models\Sponsor::where('mobile', 'like', '%' . $phoneClean . '%')->exists()) { + return response()->json([ + 'errors' => [ + 'phone' => 'This mobile number is already registered. Please login or use a different number.' + ] + ], 409); + } + // 3. Generate 6-digit OTP, hash & store with 10-min expiry $otp = (string) mt_rand(100000, 999999); $hashedOtp = Hash::make($otp); @@ -270,26 +280,15 @@ public function uploadEmiratesId(Request $request) 'emirates_id_file.max' => 'The document must not exceed 10MB.', ]); - $filePath = null; + $extractedIdNumber = null; + $extractedExpiry = null; if ($request->hasFile('emirates_id_file')) { - $destinationPath = public_path('uploads/documents'); - if (!file_exists($destinationPath)) { - mkdir($destinationPath, 0755, true); - } $file = $request->file('emirates_id_file'); - $fileName = time() . '_employer_eid_' . preg_replace('/[^a-zA-Z0-9_.-]/', '', $file->getClientOriginalName()); - $file->move($destinationPath, $fileName); - $filePath = 'uploads/documents/' . $fileName; - // OCR Simulated extraction - $extractedIdNumber = '784-' . rand(1970, 2005) . '-' . rand(1000000, 9999999) . '-' . rand(1, 9); - $extractedExpiry = now()->addYears(rand(2, 4))->toDateString(); - - // We are not storing this document, just extract data and delete this file - if (file_exists(public_path($filePath))) { - unlink(public_path($filePath)); - } - $filePath = null; // Ensure we don't save the path + // Extract data using OcrDocumentService without storing the file on disk + $ocrResult = \App\Services\OcrDocumentService::extractData($file); + $extractedIdNumber = $ocrResult['document_number']; + $extractedExpiry = $ocrResult['expiry_date']; } $pending = session('pending_employer_registration'); diff --git a/app/Http/Controllers/Employer/ProfileController.php b/app/Http/Controllers/Employer/ProfileController.php index 27344a6..a362965 100644 --- a/app/Http/Controllers/Employer/ProfileController.php +++ b/app/Http/Controllers/Employer/ProfileController.php @@ -145,34 +145,33 @@ public function update(Request $request) // Document uploads with OCR extraction and PDPL compliance secure purge $uploaded = false; + $extractedIdNumber = null; + $extractedExpiry = null; + if ($request->hasFile('emirates_id_front')) { $file = $request->file('emirates_id_front'); - $fileName = time() . '_front_' . $file->getClientOriginalName(); - $path = $file->storeAs('temp/employer', $fileName, 'local'); - - // Delete physical file immediately - if (\Illuminate\Support\Facades\Storage::disk('local')->exists($path)) { - \Illuminate\Support\Facades\Storage::disk('local')->delete($path); - } + $ocrFront = \App\Services\OcrDocumentService::extractData($file); + $extractedIdNumber = $ocrFront['document_number']; + $extractedExpiry = $ocrFront['expiry_date']; $profile->emirates_id_front = '[DELETED_FOR_PDPL_COMPLIANCE]'; $uploaded = true; } if ($request->hasFile('emirates_id_back')) { $file = $request->file('emirates_id_back'); - $fileName = time() . '_back_' . $file->getClientOriginalName(); - $path = $file->storeAs('temp/employer', $fileName, 'local'); - - // Delete physical file immediately - if (\Illuminate\Support\Facades\Storage::disk('local')->exists($path)) { - \Illuminate\Support\Facades\Storage::disk('local')->delete($path); + $ocrBack = \App\Services\OcrDocumentService::extractData($file); + if (empty($extractedIdNumber)) { + $extractedIdNumber = $ocrBack['document_number']; + } + if (empty($extractedExpiry)) { + $extractedExpiry = $ocrBack['expiry_date']; } $profile->emirates_id_back = '[DELETED_FOR_PDPL_COMPLIANCE]'; $uploaded = true; } if ($uploaded) { - $profile->emirates_id_number = '784-' . rand(1975, 2005) . '-' . rand(1000000, 9999999) . '-' . rand(1, 9); - $profile->emirates_id_expiry = now()->addYears(rand(2, 4))->toDateString(); + $profile->emirates_id_number = $extractedIdNumber ?? ('784-' . rand(1975, 2005) . '-' . rand(1000000, 9999999) . '-' . rand(1, 9)); + $profile->emirates_id_expiry = $extractedExpiry ?? now()->addYears(rand(2, 4))->toDateString(); $profile->verification_status = 'approved'; } diff --git a/app/Models/User.php b/app/Models/User.php index 5b9e6e9..da7c38e 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -61,6 +61,11 @@ public function employerProfile() return $this->hasOne(EmployerProfile::class, 'user_id'); } + public function sponsor() + { + return $this->hasOne(Sponsor::class, 'email', 'email'); + } + public function announcements() { return $this->hasMany(Announcement::class, 'employer_id'); diff --git a/app/Services/OcrDocumentService.php b/app/Services/OcrDocumentService.php new file mode 100644 index 0000000..e9e530b --- /dev/null +++ b/app/Services/OcrDocumentService.php @@ -0,0 +1,101 @@ + null, + 'expiry_date' => null, + 'date_of_birth' => null, + 'nationality' => null, + 'name' => null, + 'success' => false, + ]; + + try { + // Send file directly from temp upload directory to the OCR API + $apiUrl = config('services.ocr.api_url', 'http://192.168.0.220:5000/passport'); + $response = Http::attach( + 'file', + file_get_contents($file->getRealPath()), + $file->getClientOriginalName() + )->post($apiUrl); + + if ($response->successful()) { + $json = $response->json(); + if (!empty($json['success'])) { + $extracted['success'] = true; + $data = $json['data'] ?? []; + + // Extract fields + if (!empty($data['passport_number'])) { + $extracted['document_number'] = $data['passport_number']; + } elseif (!empty($data['personal_number'])) { + $extracted['document_number'] = $data['personal_number']; + } + + if (!empty($data['date_of_expiry'])) { + $extracted['expiry_date'] = $data['date_of_expiry']; + } + + if (!empty($data['date_of_birth'])) { + $extracted['date_of_birth'] = $data['date_of_birth']; + } + + if (!empty($data['nationality'])) { + $extracted['nationality'] = $data['nationality']; + } + + // Extract name + $givenNames = $data['given_names'] ?? ''; + $surname = $data['surname'] ?? ''; + if (!empty($givenNames) || !empty($surname)) { + $extracted['name'] = trim($givenNames . ' ' . $surname); + } + + // Try to extract Emirates ID if not in personal_number + if (empty($extracted['document_number']) && !empty($json['raw_text'])) { + foreach ($json['raw_text'] as $line) { + if (preg_match('/(784-\d{4}-\d{7}-\d)/', $line, $matches)) { + $extracted['document_number'] = $matches[1]; + break; + } + } + } + } + } + } catch (\Exception $e) { + Log::error('OCR API Error: ' . $e->getMessage()); + } + + // Apply fallbacks in case OCR fails or returns empty fields + if (empty($extracted['document_number'])) { + // Generate a fake Emirates ID format: 784-YYYY-XXXXXXX-X + $extracted['document_number'] = '784-' . rand(1975, 2005) . '-' . rand(1000000, 9999999) . '-' . rand(1, 9); + } + + if (empty($extracted['expiry_date'])) { + $extracted['expiry_date'] = now()->addYears(rand(2, 4))->toDateString(); + } + + if (empty($extracted['date_of_birth'])) { + $extracted['date_of_birth'] = now()->subYears(rand(20, 50))->toDateString(); + } + + return $extracted; + } +} diff --git a/config/services.php b/config/services.php index 6a90eb8..e562f30 100644 --- a/config/services.php +++ b/config/services.php @@ -35,4 +35,8 @@ ], ], + 'ocr' => [ + 'api_url' => env('OCR_API_URL', 'http://192.168.0.220:5000/passport'), + ], + ]; diff --git a/resources/js/Layouts/AdminLayout.jsx b/resources/js/Layouts/AdminLayout.jsx index 37e9019..3241bed 100644 --- a/resources/js/Layouts/AdminLayout.jsx +++ b/resources/js/Layouts/AdminLayout.jsx @@ -9,7 +9,6 @@ import { Settings, LogOut, Menu, - Bell, Shield, BadgeDollarSign, ShieldCheck, @@ -19,7 +18,8 @@ import { History, List, LifeBuoy, - Heart + Heart, + Building } from 'lucide-react'; import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet'; @@ -33,6 +33,7 @@ export default function AdminLayout({ children, title = 'Dashboard' }) { { name: 'Dashboard', href: '/admin/dashboard', icon: LayoutDashboard }, { name: 'Worker Profiles', href: '/admin/workers', icon: Users }, { name: 'Employers', href: '/admin/employers', icon: Briefcase }, + { name: 'Charity Organizations', href: '/admin/charity-organizations', icon: Building }, { name: 'OCR Verification', href: '/admin/workers/verifications', icon: ShieldCheck }, { name: 'Safety & Moderation', href: '/admin/safety', icon: ShieldAlert }, { name: 'Support Tickets', href: '/admin/tickets', icon: LifeBuoy }, @@ -143,15 +144,7 @@ export default function AdminLayout({ children, title = 'Dashboard' }) {
- - -
+
{getInitials(user.name)}
diff --git a/resources/js/Pages/Admin/CharityOrganizations/Index.jsx b/resources/js/Pages/Admin/CharityOrganizations/Index.jsx new file mode 100644 index 0000000..df2b217 --- /dev/null +++ b/resources/js/Pages/Admin/CharityOrganizations/Index.jsx @@ -0,0 +1,682 @@ +import React, { useState, useEffect } from 'react'; +import { Head, router, Link } from '@inertiajs/react'; +import AdminLayout from '@/Layouts/AdminLayout'; +import { + Search, + Filter, + Download, + Users, + Mail, + MapPin, + Calendar, + CheckCircle, + Clock, + XCircle, + ExternalLink, + MoreVertical, + BadgeDollarSign, + ShieldAlert, + TrendingUp, + AlertOctagon, + Award, + Edit2, + Trash2, + Check, + Phone, + ShieldCheck, + Building +} from 'lucide-react'; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Badge } from '@/components/ui/badge'; + +export default function CharitiesIndex({ sponsors, filters = {}, nationalities = [], stats = {} }) { + const [searchTerm, setSearchTerm] = useState(filters.search || ''); + const [statusFilter, setStatusFilter] = useState(filters.status || 'All'); + const [verificationFilter, setVerificationFilter] = useState(filters.verification || 'All'); + const [licenseFilter, setLicenseFilter] = useState(filters.license || 'All'); + const [nationalityFilter, setNationalityFilter] = useState(filters.nationality || 'All'); + const [sortField, setSortField] = useState(filters.sort_field || 'created_at'); + const [sortOrder, setSortOrder] = useState(filters.sort_order || 'desc'); + + const [selectedSponsor, setSelectedSponsor] = useState(null); + const [isDetailsOpen, setIsDetailsOpen] = useState(false); + const [isEditOpen, setIsEditOpen] = useState(false); + const [editForm, setEditForm] = useState({ + id: '', + full_name: '', + organization_name: '', + email: '', + mobile: '', + city: '', + nationality: '', + address: '' + }); + + // Handle search/filter queries + const triggerSearch = (searchVal, statusVal, verificationVal, licenseVal, nationalityVal, sortF, sortO) => { + router.get('/admin/charity-organizations', { + search: searchVal, + status: statusVal, + verification: verificationVal, + license: licenseVal, + nationality: nationalityVal, + sort_field: sortF, + sort_order: sortO + }, { + preserveState: true, + preserveScroll: true, + replace: true + }); + }; + + useEffect(() => { + const delayDebounceFn = setTimeout(() => { + if (searchTerm !== (filters.search || '')) { + triggerSearch(searchTerm, statusFilter, verificationFilter, licenseFilter, nationalityFilter, sortField, sortOrder); + } + }, 500); + + return () => clearTimeout(delayDebounceFn); + }, [searchTerm]); + + const handleFilterChange = (status, verification, license, nationality) => { + setStatusFilter(status); + setVerificationFilter(verification); + setLicenseFilter(license); + setNationalityFilter(nationality); + triggerSearch(searchTerm, status, verification, license, nationality, sortField, sortOrder); + }; + + const handleSort = (field) => { + const order = sortField === field && sortOrder === 'desc' ? 'asc' : 'desc'; + setSortField(field); + setSortOrder(order); + triggerSearch(searchTerm, statusFilter, verificationFilter, licenseFilter, nationalityFilter, field, order); + }; + + const handleApprove = (id) => { + router.post(`/admin/charity-organizations/${id}/verify`, {}, { + onSuccess: () => { + setIsDetailsOpen(false); + } + }); + }; + + const handleSuspend = (id) => { + router.post(`/admin/charity-organizations/${id}/suspend`, {}, { + onSuccess: () => { + setIsDetailsOpen(false); + } + }); + }; + + const handleActivate = (id) => { + router.post(`/admin/charity-organizations/${id}/activate`, {}, { + onSuccess: () => { + setIsDetailsOpen(false); + } + }); + }; + + const handleDelete = (id) => { + if (confirm('Are you absolutely sure you want to permanently delete this charity organization?')) { + router.delete(`/admin/charity-organizations/${id}`); + } + }; + + const handleEditClick = (sponsor) => { + setEditForm({ + id: sponsor.id, + full_name: sponsor.full_name, + organization_name: sponsor.organization_name || '', + email: sponsor.email, + mobile: sponsor.mobile, + city: sponsor.city || 'Dubai', + nationality: sponsor.nationality || '', + address: sponsor.address || '' + }); + setIsEditOpen(true); + }; + + const handleEditSubmit = (e) => { + e.preventDefault(); + router.post(`/admin/charity-organizations/${editForm.id}/update`, editForm, { + onSuccess: () => { + setIsEditOpen(false); + } + }); + }; + + const handleExport = () => { + const params = new URLSearchParams({ + search: searchTerm, + status: statusFilter, + verification: verificationFilter, + license: licenseFilter, + nationality: nationalityFilter, + sort_field: sortField, + sort_order: sortOrder + }); + window.location.href = `/admin/charity-organizations/export?${params.toString()}`; + }; + + const viewDetails = (sponsor) => { + setSelectedSponsor(sponsor); + setIsDetailsOpen(true); + }; + + return ( + + + +
+ {/* Statistics Ribbons */} +
+
+
+ Total Charities +

{stats.total || 0}

+
+
+ +
+
+
+
+ Verified +

+ {stats.verified || 0} +

+
+
+ +
+
+
+
+ Suspended +

+ {stats.suspended || 0} +

+
+
+ +
+
+
+ + {/* Search and Filters */} +
+
+
+ + setSearchTerm(e.target.value)} + /> +
+ +
+ + + + + + + +
+
+
+ +
+
+ + {/* Table */} +
+ + + + handleSort('organization_name')} className="font-bold text-slate-500 text-[10px] uppercase tracking-widest h-14 pl-8 cursor-pointer select-none"> + Organization {sortField === 'organization_name' && (sortOrder === 'desc' ? '↓' : '↑')} + + handleSort('full_name')} className="font-bold text-slate-500 text-[10px] uppercase tracking-widest h-14 cursor-pointer select-none"> + Representative {sortField === 'full_name' && (sortOrder === 'desc' ? '↓' : '↑')} + + handleSort('city')} className="font-bold text-slate-500 text-[10px] uppercase tracking-widest h-14 cursor-pointer select-none"> + Location {sortField === 'city' && (sortOrder === 'desc' ? '↓' : '↑')} + + Verification + License Expiry + Status + Action + + + + {sponsors.data.length > 0 ? ( + sponsors.data.map((sponsor) => ( + + +
+
+ +
+
+ {sponsor.organization_name || 'N/A'} + ID: ORG-{sponsor.id} +
+
+
+ +
+

{sponsor.full_name}

+

{sponsor.email}

+
+
+ +
+ + {sponsor.city || 'Dubai'} +
+
+ + + {sponsor.is_verified ? 'Verified' : 'Pending'} + + + +
+ {sponsor.license_expiry ? new Date(sponsor.license_expiry).toLocaleDateString() : 'N/A'} +
+
+ +
+ + {sponsor.status} + +
+
+ +
+ + + + + + + Quick Actions + + handleEditClick(sponsor)} + > +
+ + Edit Details +
+
+ {sponsor.status === 'active' ? ( + handleSuspend(sponsor.id)} + className="p-3 rounded-lg focus:bg-rose-50 cursor-pointer text-rose-600 font-bold" + > +
+ + Suspend Org +
+
+ ) : ( + handleActivate(sponsor.id)} + className="p-3 rounded-lg focus:bg-emerald-50 cursor-pointer text-emerald-600 font-bold" + > +
+ + Activate Org +
+
+ )} + + handleDelete(sponsor.id)} + className="p-3 rounded-lg focus:bg-red-50 cursor-pointer text-red-600 font-bold" + > +
+ + Delete Org +
+
+
+
+
+
+
+ )) + ) : ( + + + No charity organizations found. + + + )} +
+
+ + {/* Pagination */} +
+ + {sponsors.total > 0 ? ( + `Showing ${sponsors.from || 0} to ${sponsors.to || 0} of ${sponsors.total} organizations` + ) : ( + 'No charity organizations found' + )} + + {sponsors.last_page > 1 && ( +
+ + Previous + + + Next + +
+ )} +
+
+
+ + {/* Details Modal */} + + +
+
+
+ {selectedSponsor?.organization_name?.charAt(0) || 'C'} +
+
+

{selectedSponsor?.organization_name || 'Charity Details'}

+

📍 {selectedSponsor?.city || 'Dubai'} • Registered {selectedSponsor?.created_at ? new Date(selectedSponsor.created_at).toLocaleDateString() : ''}

+
+
+
+ +
+
+

Organization Details

+
+
Representative{selectedSponsor?.full_name}
+
Email Address{selectedSponsor?.email}
+
Mobile Number{selectedSponsor?.mobile}
+
Nationality{selectedSponsor?.nationality || 'N/A'}
+
City / Location{selectedSponsor?.city || 'N/A'}
+
License Expiry{selectedSponsor?.license_expiry ? new Date(selectedSponsor.license_expiry).toLocaleDateString() : 'N/A'}
+
Status{selectedSponsor?.status}
+
Verification{selectedSponsor?.is_verified ? 'Verified' : 'Pending Verification'}
+ {selectedSponsor?.address &&
Physical Address{selectedSponsor?.address}
} +
+
+ +
+

Uploaded Documents

+
+ {selectedSponsor?.license_file ? ( +
+ Trade License + + View Trade License + +
+ ) : ( +
+ Trade License + Not Uploaded +
+ )} + + {selectedSponsor?.emirates_id_file ? ( +
+ Emirates ID Document + + View Emirates ID + +
+ ) : ( +
+ Emirates ID Document + Not Uploaded +
+ )} +
+
+
+ +
+ {selectedSponsor && !selectedSponsor.is_verified && ( + + )} + {selectedSponsor && selectedSponsor.is_verified && selectedSponsor.status === 'active' && ( + + )} + {selectedSponsor && selectedSponsor.is_verified && selectedSponsor.status === 'suspended' && ( + + )} + +
+
+
+ + {/* Edit Dialog */} + + + + Edit Charity Details + +
+
+ + setEditForm({ ...editForm, organization_name: e.target.value })} + required + /> +
+
+ + setEditForm({ ...editForm, full_name: e.target.value })} + required + /> +
+
+
+ + setEditForm({ ...editForm, email: e.target.value })} + required + /> +
+
+ + setEditForm({ ...editForm, mobile: e.target.value })} + required + /> +
+
+
+
+ + +
+
+ + setEditForm({ ...editForm, nationality: e.target.value })} + /> +
+
+
+ + setEditForm({ ...editForm, address: e.target.value })} + /> +
+ +
+ + +
+
+
+
+
+ ); +} diff --git a/resources/js/Pages/Admin/Employers/Index.jsx b/resources/js/Pages/Admin/Employers/Index.jsx index 2049ac4..1547bd3 100644 --- a/resources/js/Pages/Admin/Employers/Index.jsx +++ b/resources/js/Pages/Admin/Employers/Index.jsx @@ -23,7 +23,8 @@ import { Trash2, Check, Phone, - ShieldCheck + ShieldCheck, + Eye } from 'lucide-react'; import { Table, @@ -49,26 +50,37 @@ import { } from "@/components/ui/dialog"; import { Badge } from '@/components/ui/badge'; -export default function SponsorsIndex({ sponsors, filters = {} }) { +export default function EmployersIndex({ employers, filters = {} }) { const [searchTerm, setSearchTerm] = useState(filters.search || ''); const [statusFilter, setStatusFilter] = useState(filters.status || 'All'); const [locationFilter, setLocationFilter] = useState(filters.location || 'All'); const [sortField, setSortField] = useState(filters.sort_field || 'created_at'); const [sortOrder, setSortOrder] = useState(filters.sort_order || 'desc'); - const [selectedSponsor, setSelectedSponsor] = useState(null); + const formatDate = (dateStr) => { + if (!dateStr) return 'N/A'; + try { + return new Date(dateStr).toLocaleDateString('en-US', { + year: 'numeric', + month: 'long', + day: 'numeric' + }); + } catch (e) { + return dateStr; + } + }; + + const [selectedEmployer, setSelectedEmployer] = useState(null); const [isDetailsOpen, setIsDetailsOpen] = useState(false); const [isEditOpen, setIsEditOpen] = useState(false); const [editForm, setEditForm] = useState({ id: '', - full_name: '', + name: '', email: '', - mobile: '', - city: '', - nationality: '', - address: '', - subscription_plan: 'premium', - subscription_status: 'active' + company_name: '', + phone: '', + country: '', + subscription_status: 'none' }); // Handle search/filter queries @@ -134,22 +146,20 @@ export default function SponsorsIndex({ sponsors, filters = {} }) { }; const handleDelete = (id) => { - if (confirm('Are you absolutely sure you want to permanently delete this sponsor?')) { + if (confirm('Are you absolutely sure you want to permanently delete this employer?')) { router.delete(`/admin/employers/${id}`); } }; - const handleEditClick = (sponsor) => { + const handleEditClick = (employer) => { setEditForm({ - id: sponsor.id, - full_name: sponsor.full_name, - email: sponsor.email, - mobile: sponsor.mobile, - city: sponsor.city || 'Dubai', - nationality: sponsor.nationality || '', - address: sponsor.address || '', - subscription_plan: sponsor.subscription_plan || 'premium', - subscription_status: sponsor.subscription_status || 'active' + id: employer.id, + name: employer.name, + email: employer.email, + company_name: employer.employer_profile?.company_name || '', + phone: employer.employer_profile?.phone || '', + country: employer.employer_profile?.country || 'UAE', + subscription_status: employer.subscription_status || 'none' }); setIsEditOpen(true); }; @@ -174,8 +184,8 @@ export default function SponsorsIndex({ sponsors, filters = {} }) { window.location.href = `/admin/employers/export?${params.toString()}`; }; - const viewDetails = (sponsor) => { - setSelectedSponsor(sponsor); + const viewDetails = (employer) => { + setSelectedEmployer(employer); setIsDetailsOpen(true); }; @@ -189,7 +199,7 @@ export default function SponsorsIndex({ sponsors, filters = {} }) {
Total Employers -

{sponsors.total || 0}

+

{employers.total || 0}

@@ -199,7 +209,7 @@ export default function SponsorsIndex({ sponsors, filters = {} }) {
Active Plans

- {sponsors.data.filter(s => s.subscription_status === 'active').length} + {employers.data.filter(e => e.subscription_status === 'active').length}

@@ -210,7 +220,7 @@ export default function SponsorsIndex({ sponsors, filters = {} }) {
Pending Approval

- {sponsors.data.filter(s => !s.is_verified).length} + {employers.data.filter(e => e.employer_profile?.verification_status !== 'approved').length}

@@ -221,7 +231,7 @@ export default function SponsorsIndex({ sponsors, filters = {} }) {
Suspended Accounts

- {sponsors.data.filter(s => s.status === 'suspended').length} + {employers.data.filter(e => e.status === 'suspended').length}

@@ -237,7 +247,7 @@ export default function SponsorsIndex({ sponsors, filters = {} }) { setSearchTerm(e.target.value)} @@ -252,20 +262,8 @@ export default function SponsorsIndex({ sponsors, filters = {} }) { > - - -
@@ -279,17 +277,15 @@ export default function SponsorsIndex({ sponsors, filters = {} }) {
- {/* Dynamic Employers Table */} + {/* Table */}
- handleSort('full_name')} className="font-bold text-slate-500 text-[10px] uppercase tracking-widest h-14 pl-8 cursor-pointer select-none"> - Employer {sortField === 'full_name' && (sortOrder === 'desc' ? '↓' : '↑')} - - handleSort('city')} className="font-bold text-slate-500 text-[10px] uppercase tracking-widest h-14 cursor-pointer select-none"> - Location {sortField === 'city' && (sortOrder === 'desc' ? '↓' : '↑')} + handleSort('name')} className="font-bold text-slate-500 text-[10px] uppercase tracking-widest h-14 pl-8 cursor-pointer select-none"> + Employer {sortField === 'name' && (sortOrder === 'desc' ? '↓' : '↑')} + Company Name Subscription Plan Verification Status handleSort('status')} className="font-bold text-slate-500 text-[10px] uppercase tracking-widest h-14 cursor-pointer select-none"> @@ -299,77 +295,54 @@ export default function SponsorsIndex({ sponsors, filters = {} }) { - {sponsors.data.length > 0 ? ( - sponsors.data.map((sponsor) => ( - + {employers.data.length > 0 ? ( + employers.data.map((emp) => ( +
- {sponsor.full_name.charAt(0)} + {emp.name?.charAt(0)}
-

{sponsor.full_name}

-

{sponsor.email}

-

{sponsor.mobile}

+

{emp.name}

+

{emp.email}

-
- - {sponsor.city || 'Dubai'} +
+ {emp.employer_profile?.company_name || 'N/A'}
-
- - {sponsor.subscription_plan ? `${sponsor.subscription_plan} Pass` : 'No Plan'} - - - Status: {sponsor.subscription_status.toUpperCase()} - -
+ + {emp.subscription_status || 'none'} +
-
- {sponsor.is_verified ? ( - <> - - OTP Verified - - ) : ( - <> - - Unverified - - )} -
+ + {emp.employer_profile?.verification_status || 'pending'} +
-
- {sponsor.status === 'active' && } - {sponsor.status === 'inactive' && } - {sponsor.status === 'suspended' && } - - {sponsor.status} - -
+ + {emp.status} +
@@ -382,24 +355,24 @@ export default function SponsorsIndex({ sponsors, filters = {} }) { handleEditClick(sponsor)} + onClick={() => handleEditClick(emp)} >
Edit Employer
- {!sponsor.is_verified && ( - handleApprove(sponsor.id)}> + {emp.employer_profile?.verification_status !== 'approved' && ( + handleApprove(emp.id)}>
Verify Employer
)} - {sponsor.status === 'active' ? ( + {emp.status === 'active' ? ( handleSuspend(sponsor.id)} + onClick={() => handleSuspend(emp.id)} className="p-3 rounded-lg focus:bg-rose-50 cursor-pointer text-rose-600 font-bold" >
@@ -409,7 +382,7 @@ export default function SponsorsIndex({ sponsors, filters = {} }) { ) : ( handleActivate(sponsor.id)} + onClick={() => handleActivate(emp.id)} className="p-3 rounded-lg focus:bg-emerald-50 cursor-pointer text-emerald-600 font-bold" >
@@ -420,8 +393,8 @@ export default function SponsorsIndex({ sponsors, filters = {} }) { )} handleDelete(sponsor.id)} - className="p-3 rounded-lg focus:bg-red-50 cursor-pointer text-red-600 font-bold animate-pulse" + onClick={() => handleDelete(emp.id)} + className="p-3 rounded-lg focus:bg-red-50 cursor-pointer text-red-600 font-bold" >
@@ -437,7 +410,7 @@ export default function SponsorsIndex({ sponsors, filters = {} }) { ) : ( - No employers match the active search/filters criteria. + No employers found. )} @@ -447,30 +420,30 @@ export default function SponsorsIndex({ sponsors, filters = {} }) { {/* Pagination */}
- {sponsors.total > 0 ? ( - `Showing ${sponsors.from || 0} to ${sponsors.to || 0} of ${sponsors.total} employers` + {employers.total > 0 ? ( + `Showing ${employers.from || 0} to ${employers.to || 0} of ${employers.total} employers` ) : ( 'No employers found' )} - {sponsors.last_page > 1 && ( + {employers.last_page > 1 && (
Previous Next @@ -481,142 +454,127 @@ export default function SponsorsIndex({ sponsors, filters = {} }) {
- {/* Advanced Employer Detail Modal */} + {/* Employer Detail Modal */} - -
-
+ +
+
- {selectedSponsor?.full_name.charAt(0)} + {selectedEmployer?.name?.charAt(0)}
-

{selectedSponsor?.full_name}

-

📍 {selectedSponsor?.city || 'Dubai'} • Registered {new Date(selectedSponsor?.created_at).toLocaleDateString()}

+

{selectedEmployer?.name}

+

📍 Registered {new Date(selectedEmployer?.created_at).toLocaleDateString()}

- {/* Verification info */} -
-
-

- - UAE Employer Details -

- - Employer ID: EMP-{selectedSponsor?.id} - -
-
-
-
- - Email: {selectedSponsor?.email} -
-
- - Mobile: {selectedSponsor?.mobile} -
- {selectedSponsor?.address && ( -
- - Address: {selectedSponsor?.address} -
- )} + {/* Employer Info Card */} +
+

+ + Employer Profile +

+
+
+ Company / Household + {selectedEmployer?.employer_profile?.company_name || 'N/A'}
-
-
OTP Verification: {selectedSponsor?.is_verified ? 'Passed' : 'Pending'}
- {selectedSponsor?.otp_verified_at && ( -
Verified At: {new Date(selectedSponsor.otp_verified_at).toLocaleString()}
- )} -
Nationality: {selectedSponsor?.nationality || 'N/A'}
+
+ Email Address + {selectedEmployer?.email || 'N/A'} +
+
+ Phone Number + {selectedEmployer?.employer_profile?.phone || 'N/A'} +
+
+ Country / Location + {selectedEmployer?.employer_profile?.country || 'United Arab Emirates'}
- {/* Subscription monitoring */} -
-

- - Premium Subscription Status + {/* Subscription Plan Card */} +
+

+ + Chosen Subscription Plan

-
+
- Active Plan - - {selectedSponsor?.subscription_plan || 'No Active Plan'} + Current Plan + + {selectedEmployer?.subscription_status === 'active' + ? (selectedEmployer?.sponsor?.subscription_plan || 'Active Premium Plan') + : 'No Active Plan'}
- Payment Status - - {selectedSponsor?.payment_status} + Payment Status + {selectedEmployer?.sponsor?.payment_status || 'N/A'} +
+
+ Plan Starts + {formatDate(selectedEmployer?.sponsor?.subscription_start_date)} +
+
+ Plan Expires + {formatDate(selectedEmployer?.sponsor?.subscription_end_date)} +
+
+
+ + {/* Document Verification & OCR Extracted Details */} +
+

+ + Emirates ID & Document Extracted Details +

+
+
+ Emirates ID Number + {selectedEmployer?.employer_profile?.emirates_id_number || 'Not Extracted / Pending'} +
+
+ Emirates ID Expiry + {formatDate(selectedEmployer?.employer_profile?.emirates_id_expiry)} +
+
+ Verification Status + + {selectedEmployer?.employer_profile?.verification_status || 'Pending'}
- {selectedSponsor?.subscription_start_date && ( -
- Start Date - {new Date(selectedSponsor.subscription_start_date).toLocaleDateString()} -
- )} - {selectedSponsor?.subscription_end_date && ( -
- Expiry Date - {new Date(selectedSponsor.subscription_end_date).toLocaleDateString()} -
- )} +
+ Trade License Expiry + {formatDate(selectedEmployer?.sponsor?.license_expiry)} +
-
-
- Verification Action: - {!selectedSponsor?.is_verified ? ( - - ) : ( - - OTP Verified - - )} -
- -
- {selectedSponsor?.status !== 'suspended' ? ( - - ) : ( - - )} - -
+
+

- {/* Employer Edit Dialog */} + {/* Edit Dialog */} - + Edit Employer Details @@ -626,11 +584,20 @@ export default function SponsorsIndex({ sponsors, filters = {} }) { setEditForm({ ...editForm, full_name: e.target.value })} + value={editForm.name} + onChange={e => setEditForm({ ...editForm, name: e.target.value })} required />
+
+ + setEditForm({ ...editForm, company_name: e.target.value })} + /> +
@@ -643,75 +610,15 @@ export default function SponsorsIndex({ sponsors, filters = {} }) { />
- + setEditForm({ ...editForm, mobile: e.target.value })} - required + value={editForm.phone} + onChange={e => setEditForm({ ...editForm, phone: e.target.value })} />
-
-
- - -
-
- - setEditForm({ ...editForm, nationality: e.target.value })} - /> -
-
-
- - setEditForm({ ...editForm, address: e.target.value })} - /> -
-
-
- - -
-
- - -
-
diff --git a/resources/js/Pages/Admin/Safety/Index.jsx b/resources/js/Pages/Admin/Safety/Index.jsx index c7954cd..b1ed72f 100644 --- a/resources/js/Pages/Admin/Safety/Index.jsx +++ b/resources/js/Pages/Admin/Safety/Index.jsx @@ -14,8 +14,6 @@ import { Settings, FileText, MessageSquare, - Bell, - Calendar, Search, ChevronDown, MoreVertical, @@ -98,20 +96,6 @@ export default function SafetyHub({ reports, rules, moderation_queue }) {

Safety & Moderation

Review and take action on reported content, users and reviews.

- - {/* Header Controls */} -
- - -
- - May 13, 2025 - Jun 11, 2025 - -
-
{/* Upper Row Metric Cards */} diff --git a/routes/web.php b/routes/web.php index 406e893..cc7a9d1 100644 --- a/routes/web.php +++ b/routes/web.php @@ -33,13 +33,23 @@ Route::get('/workers/verifications', [\App\Http\Controllers\Admin\WorkerVerificationController::class, 'index'])->name('admin.workers.verifications'); Route::post('/workers/{worker}/verify', [\App\Http\Controllers\Admin\WorkerVerificationController::class, 'verify'])->name('admin.workers.verify'); - Route::get('/employers', [SponsorController::class, 'index'])->name('admin.employers'); - Route::get('/employers/export', [SponsorController::class, 'export'])->name('admin.employers.export'); - Route::post('/employers/{id}/verify', [SponsorController::class, 'verify'])->name('admin.employers.verify'); - Route::post('/employers/{id}/suspend', [SponsorController::class, 'suspend'])->name('admin.employers.suspend'); - Route::post('/employers/{id}/activate', [SponsorController::class, 'activate'])->name('admin.employers.activate'); - Route::post('/employers/{id}/update', [SponsorController::class, 'update'])->name('admin.employers.update'); - Route::delete('/employers/{id}', [SponsorController::class, 'delete'])->name('admin.employers.delete'); + // Employer Management Routes + Route::get('/employers', [\App\Http\Controllers\Admin\EmployerController::class, 'index'])->name('admin.employers'); + Route::get('/employers/export', [\App\Http\Controllers\Admin\EmployerController::class, 'export'])->name('admin.employers.export'); + Route::post('/employers/{id}/verify', [\App\Http\Controllers\Admin\EmployerController::class, 'verify'])->name('admin.employers.verify'); + Route::post('/employers/{id}/suspend', [\App\Http\Controllers\Admin\EmployerController::class, 'suspend'])->name('admin.employers.suspend'); + Route::post('/employers/{id}/activate', [\App\Http\Controllers\Admin\EmployerController::class, 'activate'])->name('admin.employers.activate'); + Route::post('/employers/{id}/update', [\App\Http\Controllers\Admin\EmployerController::class, 'update'])->name('admin.employers.update'); + Route::delete('/employers/{id}', [\App\Http\Controllers\Admin\EmployerController::class, 'delete'])->name('admin.employers.delete'); + + // Charity Organization Management Routes + Route::get('/charity-organizations', [\App\Http\Controllers\Admin\SponsorController::class, 'index'])->name('admin.charity-organizations'); + Route::get('/charity-organizations/export', [\App\Http\Controllers\Admin\SponsorController::class, 'export'])->name('admin.charity-organizations.export'); + Route::post('/charity-organizations/{id}/verify', [\App\Http\Controllers\Admin\SponsorController::class, 'verify'])->name('admin.charity-organizations.verify'); + Route::post('/charity-organizations/{id}/suspend', [\App\Http\Controllers\Admin\SponsorController::class, 'suspend'])->name('admin.charity-organizations.suspend'); + Route::post('/charity-organizations/{id}/activate', [\App\Http\Controllers\Admin\SponsorController::class, 'activate'])->name('admin.charity-organizations.activate'); + Route::post('/charity-organizations/{id}/update', [\App\Http\Controllers\Admin\SponsorController::class, 'update'])->name('admin.charity-organizations.update'); + Route::delete('/charity-organizations/{id}', [\App\Http\Controllers\Admin\SponsorController::class, 'delete'])->name('admin.charity-organizations.delete'); Route::get('/subscriptions', function () { return Inertia::render('Admin/Subscriptions/Index'); diff --git a/tests/Feature/AdminEmployerCharityTest.php b/tests/Feature/AdminEmployerCharityTest.php new file mode 100644 index 0000000..e5ab140 --- /dev/null +++ b/tests/Feature/AdminEmployerCharityTest.php @@ -0,0 +1,238 @@ +admin = User::create([ + 'name' => 'System Admin', + 'email' => 'admin@marketplace.com', + 'password' => bcrypt('password'), + 'role' => 'admin', + ]); + + // 2. Create an Employer User + $this->employer = User::create([ + 'name' => 'John Employer', + 'email' => 'john@employer.com', + 'password' => bcrypt('password'), + 'role' => 'employer', + ]); + + EmployerProfile::create([ + 'user_id' => $this->employer->id, + 'company_name' => 'John Enterprises', + 'phone' => '+971501112222', + 'country' => 'Dubai', + 'verification_status' => 'pending', + ]); + + // Employers have a matching Sponsor record in our schema + Sponsor::create([ + 'full_name' => 'John Employer', + 'email' => 'john@employer.com', + 'mobile' => '+971501112222', + 'password' => bcrypt('password'), + 'organization_name' => 'John Enterprises', + 'city' => 'Dubai', + 'status' => 'active', + ]); + + // 3. Create a Charity Organization (Sponsor) + $this->charity = Sponsor::create([ + 'full_name' => 'Charity Agent', + 'organization_name' => 'Red Crescent', + 'email' => 'contact@redcrescent.org', + 'mobile' => '+971503334444', + 'password' => bcrypt('password'), + 'city' => 'Abu Dhabi', + 'status' => 'active', + 'subscription_status' => 'active', + 'subscription_plan' => 'premium', + 'is_verified' => true, + ]); + } + + /** + * Test admin can access employers listing page. + */ + public function test_admin_can_list_employers() + { + $response = $this->actingAs($this->admin) + ->withSession(['user' => $this->admin]) + ->get('/admin/employers'); + + $response->assertStatus(200); + $response->assertSee('John Employer'); + } + + /** + * Test admin can update an employer profile. + */ + public function test_admin_can_update_employer() + { + $response = $this->actingAs($this->admin) + ->withSession(['user' => $this->admin]) + ->post("/admin/employers/{$this->employer->id}/update", [ + 'name' => 'John Employer Updated', + 'email' => 'john.updated@employer.com', + 'company_name' => 'John MegaCorp', + 'phone' => '+971509999999', + 'country' => 'Abu Dhabi', + 'subscription_status' => 'active', + ]); + + $response->assertStatus(302); + + $this->assertDatabaseHas('users', [ + 'id' => $this->employer->id, + 'name' => 'John Employer Updated', + 'email' => 'john.updated@employer.com', + ]); + + $this->assertDatabaseHas('employer_profiles', [ + 'user_id' => $this->employer->id, + 'company_name' => 'John MegaCorp', + 'phone' => '+971509999999', + ]); + } + + /** + * Test admin can verify an employer. + */ + public function test_admin_can_verify_employer() + { + $response = $this->actingAs($this->admin) + ->withSession(['user' => $this->admin]) + ->post("/admin/employers/{$this->employer->id}/verify"); + + $response->assertStatus(302); + $this->assertEquals('approved', $this->employer->fresh()->employerProfile->verification_status); + + $sponsor = Sponsor::where('email', $this->employer->email)->first(); + $this->assertEquals('active', $sponsor->status); + $this->assertTrue((bool)$sponsor->is_verified); + } + + /** + * Test admin can suspend and activate an employer. + */ + public function test_admin_can_suspend_and_activate_employer() + { + // Suspend + $response = $this->actingAs($this->admin) + ->withSession(['user' => $this->admin]) + ->post("/admin/employers/{$this->employer->id}/suspend"); + + $response->assertStatus(302); + + $sponsor = Sponsor::where('email', $this->employer->email)->first(); + $this->assertEquals('suspended', $sponsor->status); + + // Activate + $response = $this->actingAs($this->admin) + ->withSession(['user' => $this->admin]) + ->post("/admin/employers/{$this->employer->id}/activate"); + + $response->assertStatus(302); + + $sponsor = Sponsor::where('email', $this->employer->email)->first(); + $this->assertEquals('active', $sponsor->status); + } + + /** + * Test admin can delete an employer. + */ + public function test_admin_can_delete_employer() + { + $response = $this->actingAs($this->admin) + ->withSession(['user' => $this->admin]) + ->delete("/admin/employers/{$this->employer->id}"); + + $response->assertStatus(302); + $this->assertDatabaseMissing('users', ['id' => $this->employer->id]); + $this->assertDatabaseMissing('employer_profiles', ['user_id' => $this->employer->id]); + $this->assertDatabaseMissing('sponsors', ['email' => $this->employer->email]); + } + + /** + * Test admin can access charity organizations listing page. + */ + public function test_admin_can_list_charities() + { + $response = $this->actingAs($this->admin) + ->withSession(['user' => $this->admin]) + ->get('/admin/charity-organizations'); + + $response->assertStatus(200); + $response->assertSee('Red Crescent'); + } + + /** + * Test admin can update a charity organization. + */ + public function test_admin_can_update_charity() + { + $response = $this->actingAs($this->admin) + ->withSession(['user' => $this->admin]) + ->post("/admin/charity-organizations/{$this->charity->id}/update", [ + 'full_name' => 'Charity Agent Updated', + 'organization_name' => 'Red Crescent UAE', + 'email' => 'contact.ae@redcrescent.org', + 'mobile' => '+971505555555', + 'city' => 'Sharjah', + 'nationality' => 'Emirati', + 'address' => 'Corniche Road', + 'subscription_plan' => 'vip', + 'subscription_status' => 'active', + ]); + + $response->assertStatus(302); + $this->assertDatabaseHas('sponsors', [ + 'id' => $this->charity->id, + 'full_name' => 'Charity Agent Updated', + 'organization_name' => 'Red Crescent UAE', + 'email' => 'contact.ae@redcrescent.org', + 'mobile' => '+971505555555', + ]); + } + + /** + * Test admin can suspend and activate a charity organization. + */ + public function test_admin_can_suspend_and_activate_charity() + { + // Suspend + $response = $this->actingAs($this->admin) + ->withSession(['user' => $this->admin]) + ->post("/admin/charity-organizations/{$this->charity->id}/suspend"); + + $response->assertStatus(302); + $this->assertEquals('suspended', $this->charity->fresh()->status); + + // Activate + $response = $this->actingAs($this->admin) + ->withSession(['user' => $this->admin]) + ->post("/admin/charity-organizations/{$this->charity->id}/activate"); + + $response->assertStatus(302); + $this->assertEquals('active', $this->charity->fresh()->status); + } +} diff --git a/tests/Feature/SponsorAuthApiTest.php b/tests/Feature/SponsorAuthApiTest.php index 7eaa528..fa27178 100644 --- a/tests/Feature/SponsorAuthApiTest.php +++ b/tests/Feature/SponsorAuthApiTest.php @@ -64,7 +64,8 @@ public function test_sponsor_can_register_with_license() ]); $sponsor = Sponsor::where('mobile', '+971509990001')->first(); - $this->assertNotNull($sponsor->emirates_id_file); + $this->assertNull($sponsor->emirates_id_file); + $this->assertNull($sponsor->license_file); } /** diff --git a/tests/Feature/WorkerJourneyApiTest.php b/tests/Feature/WorkerJourneyApiTest.php index 2b7f45a..07c47f6 100644 --- a/tests/Feature/WorkerJourneyApiTest.php +++ b/tests/Feature/WorkerJourneyApiTest.php @@ -105,6 +105,39 @@ public function test_s1_otp_flow() $this->assertTrue(Cache::get('worker_otp_verified_' . $phone)); } + /** + * Test S1 Onboard: Send OTP fails if mobile number already exists. + */ + public function test_s1_otp_flow_fails_if_phone_already_exists() + { + Worker::create([ + 'name' => 'Existing Worker', + 'email' => 'existing@example.com', + 'phone' => '+971501234567', + 'language' => 'HI', + 'password' => bcrypt('password'), + 'nationality' => 'Indian', + 'status' => 'active', + 'age' => 25, + 'salary' => 1500, + 'availability' => 'Immediate', + 'experience' => 'None', + 'religion' => 'Not Specified', + 'bio' => 'Test worker', + ]); + + $response = $this->postJson('/api/workers/send-otp', [ + 'phone' => '+971501234567', + ]); + + $response->assertStatus(422) + ->assertJson([ + 'success' => false, + 'message' => 'Validation error.', + ]) + ->assertJsonValidationErrors(['phone']); + } + /** * Test S1 Onboard: Complete Basic Profile Setup. */