Merge pull request 'document upload issue api' (#8) from mohan into master

Reviewed-on: #8
This commit is contained in:
mohanmd 2026-06-15 12:17:04 +00:00
commit f733384fa2
20 changed files with 1734 additions and 430 deletions

View File

@ -62,4 +62,6 @@ AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET= AWS_BUCKET=
AWS_USE_PATH_STYLE_ENDPOINT=false AWS_USE_PATH_STYLE_ENDPOINT=false
OCR_API_URL=http://192.168.0.220:5000/passport
VITE_APP_NAME="${APP_NAME}" VITE_APP_NAME="${APP_NAME}"

View File

@ -0,0 +1,261 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\User;
use App\Models\EmployerProfile;
use App\Models\Sponsor;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Illuminate\Support\Facades\DB;
class EmployerController extends Controller
{
/**
* List all employers (role = 'employer') with filters, search, and sorting.
*/
public function index(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 (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.");
}
}

View File

@ -22,6 +22,7 @@ public function index(Request $request)
$search = $request->input('search'); $search = $request->input('search');
$query->where(function($q) use ($search) { $query->where(function($q) use ($search) {
$q->where('full_name', 'like', "%{$search}%") $q->where('full_name', 'like', "%{$search}%")
->orWhere('organization_name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%") ->orWhere('email', 'like', "%{$search}%")
->orWhere('mobile', 'like', "%{$search}%"); ->orWhere('mobile', 'like', "%{$search}%");
}); });
@ -32,15 +33,35 @@ public function index(Request $request)
$query->where('status', strtolower($request->input('status'))); $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 // Server-side Sorting
$sortField = $request->input('sort_field', 'created_at'); $sortField = $request->input('sort_field', 'created_at');
$sortOrder = $request->input('sort_order', 'desc'); $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); $query->orderBy($sortField, $sortOrder);
} else { } else {
$query->orderBy('created_at', 'desc'); $query->orderBy('created_at', 'desc');
@ -49,9 +70,41 @@ public function index(Request $request)
// Paginate // Paginate
$sponsors = $query->paginate(10)->withQueryString(); $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, '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'); $search = $request->input('search');
$query->where(function($q) use ($search) { $query->where(function($q) use ($search) {
$q->where('full_name', 'like', "%{$search}%") $q->where('full_name', 'like', "%{$search}%")
->orWhere('organization_name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%") ->orWhere('email', 'like', "%{$search}%")
->orWhere('mobile', 'like', "%{$search}%"); ->orWhere('mobile', 'like', "%{$search}%");
}); });
@ -77,15 +131,35 @@ public function export(Request $request)
$query->where('status', strtolower($request->input('status'))); $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 // Server-side Sorting
$sortField = $request->input('sort_field', 'created_at'); $sortField = $request->input('sort_field', 'created_at');
$sortOrder = $request->input('sort_order', 'desc'); $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); $query->orderBy($sortField, $sortOrder);
} else { } else {
$query->orderBy('created_at', 'desc'); $query->orderBy('created_at', 'desc');
@ -95,13 +169,13 @@ public function export(Request $request)
$headers = [ $headers = [
"Content-type" => "text/csv", "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", "Pragma" => "no-cache",
"Cache-Control" => "must-revalidate, post-check=0, pre-check=0", "Cache-Control" => "must-revalidate, post-check=0, pre-check=0",
"Expires" => "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) { $callback = function() use ($sponsors, $columns) {
$file = fopen('php://output', 'w'); $file = fopen('php://output', 'w');
@ -110,6 +184,7 @@ public function export(Request $request)
foreach ($sponsors as $sponsor) { foreach ($sponsors as $sponsor) {
fputcsv($file, [ fputcsv($file, [
$sponsor->id, $sponsor->id,
$sponsor->organization_name ?? 'N/A',
$sponsor->full_name, $sponsor->full_name,
$sponsor->email, $sponsor->email,
$sponsor->mobile, $sponsor->mobile,
@ -118,10 +193,7 @@ public function export(Request $request)
$sponsor->address ?? 'N/A', $sponsor->address ?? 'N/A',
$sponsor->status, $sponsor->status,
$sponsor->is_verified ? 'Yes' : 'No', $sponsor->is_verified ? 'Yes' : 'No',
$sponsor->otp_verified_at ? $sponsor->otp_verified_at->toDateTimeString() : 'N/A', $sponsor->license_expiry ? $sponsor->license_expiry->toDateString() : 'N/A',
$sponsor->subscription_plan ?? 'None',
$sponsor->subscription_status ?? 'None',
$sponsor->subscription_end_date ? $sponsor->subscription_end_date->toDateString() : 'N/A',
$sponsor->created_at ? $sponsor->created_at->toDateTimeString() : '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); $sponsor = Sponsor::findOrFail($id);
$validated = $request->validate([ $validated = $request->validate([
'full_name' => 'required|string|max:255', 'full_name' => 'required|string|max:255',
'organization_name' => 'nullable|string|max:255',
'email' => 'required|email|unique:sponsors,email,' . $sponsor->id, 'email' => 'required|email|unique:sponsors,email,' . $sponsor->id,
'mobile' => 'required|string', 'mobile' => 'required|string',
'city' => 'required|string', 'city' => 'required|string',

View File

@ -188,9 +188,12 @@ public function register(Request $request)
{ {
$validator = Validator::make($request->all(), [ $validator = Validator::make($request->all(), [
'name' => 'required|string|max:255', 'name' => 'required|string|max:255',
'email' => 'required|string|email|max:255|unique:users,email', 'email' => 'required|string|email|max:255|unique:users,email|unique:sponsors,email',
'phone' => 'required|string|max:50', 'phone' => 'required|string|max:50|unique:employer_profiles,phone|unique:sponsors,mobile',
'fcm_token' => 'nullable|string|max:255', '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()) { if ($validator->fails()) {
@ -409,23 +412,11 @@ public function uploadEmiratesId(Request $request)
} }
$file = $request->file('emirates_id_file'); $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()); // Extract data using OcrDocumentService without storing the file on disk
$file->move($destinationPath, $fileName); $ocrResult = \App\Services\OcrDocumentService::extractData($file);
$filePath = 'uploads/documents/' . $fileName; $extractedIdNumber = $ocrResult['document_number'];
$extractedExpiry = $ocrResult['expiry_date'];
// 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));
}
// Update or create EmployerProfile // Update or create EmployerProfile
$profile = EmployerProfile::where('user_id', $user->id)->first(); $profile = EmployerProfile::where('user_id', $user->id)->first();

View File

@ -87,7 +87,13 @@ public function updateProfile(Request $request)
'unique:users,email,' . $employer->id, 'unique:users,email,' . $employer->id,
$sponsor ? 'unique:sponsors,email,' . $sponsor->id : 'unique:sponsors,email', $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', 'company_name' => 'required|string|max:255',
'language' => 'required|string|in:English,Arabic,english,arabic', 'language' => 'required|string|in:English,Arabic,english,arabic',
'notifications' => 'required|boolean', 'notifications' => 'required|boolean',

View File

@ -24,12 +24,12 @@ public function register(Request $request)
{ {
$validator = Validator::make($request->all(), [ $validator = Validator::make($request->all(), [
'full_name' => 'required|string|max:255', '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', 'password' => 'required|string|min:6',
'license_file' => 'required|file|mimes:jpeg,png,jpg,pdf|max:10240', 'license_file' => 'required|file|mimes:jpeg,png,jpg,pdf|max:10240',
'emirates_id_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', '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', 'nationality' => 'required|string|max:100',
'city' => 'required|string|max:100', 'city' => 'required|string|max:100',
'address' => 'required|string|max:255', 'address' => 'required|string|max:255',
@ -58,29 +58,19 @@ public function register(Request $request)
$email = "sponsor.{$mobileClean}." . rand(10, 99) . "@migrant.ae"; $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'); $licenseFile = $request->file('license_file');
$licenseFileName = time() . '_license_' . preg_replace('/[^a-zA-Z0-9_.-]/', '', $licenseFile->getClientOriginalName()); $ocrLicense = \App\Services\OcrDocumentService::extractData($licenseFile);
$licenseFile->move($destinationPath, $licenseFileName); $licenseExpiry = $request->license_expiry ?? $ocrLicense['expiry_date'];
$licensePath = 'uploads/licenses/' . $licenseFileName;
$emiratesIdPath = null; $emiratesIdPath = null;
if ($request->hasFile('emirates_id_file')) { if ($request->hasFile('emirates_id_file')) {
$emiratesIdFile = $request->file('emirates_id_file'); $emiratesIdFile = $request->file('emirates_id_file');
$emiratesIdFileName = time() . '_emirates_id_' . preg_replace('/[^a-zA-Z0-9_.-]/', '', $emiratesIdFile->getClientOriginalName()); \App\Services\OcrDocumentService::extractData($emiratesIdFile);
$emiratesIdFile->move($destinationPath, $emiratesIdFileName);
$emiratesIdPath = 'uploads/licenses/' . $emiratesIdFileName;
} }
$licensePath = null;
$apiToken = Str::random(80); $apiToken = Str::random(80);
$licenseExpiry = $request->license_expiry ?? now()->addYears(3)->toDateString();
$sponsor = DB::transaction(function () use ($request, $email, $licensePath, $emiratesIdPath, $apiToken, $licenseExpiry) { $sponsor = DB::transaction(function () use ($request, $email, $licensePath, $emiratesIdPath, $apiToken, $licenseExpiry) {
return Sponsor::create([ return Sponsor::create([
'full_name' => $request->full_name, 'full_name' => $request->full_name,

View File

@ -57,11 +57,13 @@ public function skills()
public function sendOtp(Request $request) public function sendOtp(Request $request)
{ {
$validator = Validator::make($request->all(), [ $validator = Validator::make($request->all(), [
'phone' => 'required_without:email|nullable|string|max:50', 'phone' => 'required_without:email|nullable|string|max:50|unique:workers,phone',
'email' => 'required_without:phone|nullable|email|max:255', 'email' => 'required_without:phone|nullable|email|max:255|unique:workers,email',
], [ ], [
'phone.required_without' => 'Please provide a mobile number or email address.', '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.required_without' => 'Please provide a mobile number or email address.',
'email.unique' => 'This email address is already registered.',
]); ]);
if ($validator->fails()) { if ($validator->fails()) {
@ -344,32 +346,55 @@ public function register(Request $request)
} }
} }
$destinationPath = public_path('uploads/documents'); $passportData = [
if (!file_exists($destinationPath)) { 'number' => 'P' . rand(100000, 999999),
mkdir($destinationPath, 0755, true); '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; $passportPath = null;
$visaPath = 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); $inCountry = filter_var($request->input('in_country', true), FILTER_VALIDATE_BOOLEAN);
$apiToken = Str::random(80); $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([ $worker = Worker::create([
'name' => $request->name, 'name' => $request->name,
'email' => $email, 'email' => $email,
@ -404,19 +429,19 @@ public function register(Request $request)
$worker->documents()->create([ $worker->documents()->create([
'type' => 'passport', 'type' => 'passport',
'number' => 'P' . rand(100000, 999999), 'number' => $passportData['number'],
'issue_date' => now()->subYears(2)->toDateString(), 'issue_date' => $passportData['issue_date'],
'expiry_date' => now()->addYears(8)->toDateString(), 'expiry_date' => $passportData['expiry_date'],
'ocr_accuracy'=> 98.5, 'ocr_accuracy'=> 98.5,
'file_path' => $passportPath, 'file_path' => $passportPath,
]); ]);
$worker->documents()->create([ $worker->documents()->create([
'type' => 'visa', 'type' => 'visa',
'number' => 'V' . rand(100000, 999999), 'number' => $visaData['number'],
'issue_date' => now()->subYear()->toDateString(), 'issue_date' => $visaData['issue_date'],
'expiry_date' => now()->addYears(2)->toDateString(), 'expiry_date' => $visaData['expiry_date'],
'ocr_accuracy'=> $visaPath ? 98.5 : 0.0, 'ocr_accuracy'=> $visaData['ocr_accuracy'],
'file_path' => $visaPath, 'file_path' => $visaPath,
]); ]);

View File

@ -90,7 +90,7 @@ public function register(Request $request)
]); ]);
// 2. Email uniqueness check (Check DB, return 409 if duplicate) // 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([ return response()->json([
'errors' => [ 'errors' => [
'email' => 'This email address is already registered. Please login or use a different email.' 'email' => 'This email address is already registered. Please login or use a different email.'
@ -98,6 +98,16 @@ public function register(Request $request)
], 409); ], 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 // 3. Generate 6-digit OTP, hash & store with 10-min expiry
$otp = (string) mt_rand(100000, 999999); $otp = (string) mt_rand(100000, 999999);
$hashedOtp = Hash::make($otp); $hashedOtp = Hash::make($otp);
@ -270,26 +280,15 @@ public function uploadEmiratesId(Request $request)
'emirates_id_file.max' => 'The document must not exceed 10MB.', 'emirates_id_file.max' => 'The document must not exceed 10MB.',
]); ]);
$filePath = null; $extractedIdNumber = null;
$extractedExpiry = null;
if ($request->hasFile('emirates_id_file')) { 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'); $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 // Extract data using OcrDocumentService without storing the file on disk
$extractedIdNumber = '784-' . rand(1970, 2005) . '-' . rand(1000000, 9999999) . '-' . rand(1, 9); $ocrResult = \App\Services\OcrDocumentService::extractData($file);
$extractedExpiry = now()->addYears(rand(2, 4))->toDateString(); $extractedIdNumber = $ocrResult['document_number'];
$extractedExpiry = $ocrResult['expiry_date'];
// 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
} }
$pending = session('pending_employer_registration'); $pending = session('pending_employer_registration');

View File

@ -145,34 +145,33 @@ public function update(Request $request)
// Document uploads with OCR extraction and PDPL compliance secure purge // Document uploads with OCR extraction and PDPL compliance secure purge
$uploaded = false; $uploaded = false;
$extractedIdNumber = null;
$extractedExpiry = null;
if ($request->hasFile('emirates_id_front')) { if ($request->hasFile('emirates_id_front')) {
$file = $request->file('emirates_id_front'); $file = $request->file('emirates_id_front');
$fileName = time() . '_front_' . $file->getClientOriginalName(); $ocrFront = \App\Services\OcrDocumentService::extractData($file);
$path = $file->storeAs('temp/employer', $fileName, 'local'); $extractedIdNumber = $ocrFront['document_number'];
$extractedExpiry = $ocrFront['expiry_date'];
// Delete physical file immediately
if (\Illuminate\Support\Facades\Storage::disk('local')->exists($path)) {
\Illuminate\Support\Facades\Storage::disk('local')->delete($path);
}
$profile->emirates_id_front = '[DELETED_FOR_PDPL_COMPLIANCE]'; $profile->emirates_id_front = '[DELETED_FOR_PDPL_COMPLIANCE]';
$uploaded = true; $uploaded = true;
} }
if ($request->hasFile('emirates_id_back')) { if ($request->hasFile('emirates_id_back')) {
$file = $request->file('emirates_id_back'); $file = $request->file('emirates_id_back');
$fileName = time() . '_back_' . $file->getClientOriginalName(); $ocrBack = \App\Services\OcrDocumentService::extractData($file);
$path = $file->storeAs('temp/employer', $fileName, 'local'); if (empty($extractedIdNumber)) {
$extractedIdNumber = $ocrBack['document_number'];
// Delete physical file immediately }
if (\Illuminate\Support\Facades\Storage::disk('local')->exists($path)) { if (empty($extractedExpiry)) {
\Illuminate\Support\Facades\Storage::disk('local')->delete($path); $extractedExpiry = $ocrBack['expiry_date'];
} }
$profile->emirates_id_back = '[DELETED_FOR_PDPL_COMPLIANCE]'; $profile->emirates_id_back = '[DELETED_FOR_PDPL_COMPLIANCE]';
$uploaded = true; $uploaded = true;
} }
if ($uploaded) { if ($uploaded) {
$profile->emirates_id_number = '784-' . rand(1975, 2005) . '-' . rand(1000000, 9999999) . '-' . rand(1, 9); $profile->emirates_id_number = $extractedIdNumber ?? ('784-' . rand(1975, 2005) . '-' . rand(1000000, 9999999) . '-' . rand(1, 9));
$profile->emirates_id_expiry = now()->addYears(rand(2, 4))->toDateString(); $profile->emirates_id_expiry = $extractedExpiry ?? now()->addYears(rand(2, 4))->toDateString();
$profile->verification_status = 'approved'; $profile->verification_status = 'approved';
} }

View File

@ -61,6 +61,11 @@ public function employerProfile()
return $this->hasOne(EmployerProfile::class, 'user_id'); return $this->hasOne(EmployerProfile::class, 'user_id');
} }
public function sponsor()
{
return $this->hasOne(Sponsor::class, 'email', 'email');
}
public function announcements() public function announcements()
{ {
return $this->hasMany(Announcement::class, 'employer_id'); return $this->hasMany(Announcement::class, 'employer_id');

View File

@ -0,0 +1,101 @@
<?php
namespace App\Services;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class OcrDocumentService
{
/**
* Send file to OCR API http://192.168.0.220:5000/passport and extract details.
* Note: File is processed completely in-memory (from temp uploaded path), never saved to storage/public directory.
*
* @param UploadedFile $file
* @return array
*/
public static function extractData(UploadedFile $file): array
{
$extracted = [
'document_number' => 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;
}
}

View File

@ -35,4 +35,8 @@
], ],
], ],
'ocr' => [
'api_url' => env('OCR_API_URL', 'http://192.168.0.220:5000/passport'),
],
]; ];

View File

@ -9,7 +9,6 @@ import {
Settings, Settings,
LogOut, LogOut,
Menu, Menu,
Bell,
Shield, Shield,
BadgeDollarSign, BadgeDollarSign,
ShieldCheck, ShieldCheck,
@ -19,7 +18,8 @@ import {
History, History,
List, List,
LifeBuoy, LifeBuoy,
Heart Heart,
Building
} from 'lucide-react'; } from 'lucide-react';
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet'; 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: 'Dashboard', href: '/admin/dashboard', icon: LayoutDashboard },
{ name: 'Worker Profiles', href: '/admin/workers', icon: Users }, { name: 'Worker Profiles', href: '/admin/workers', icon: Users },
{ name: 'Employers', href: '/admin/employers', icon: Briefcase }, { 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: 'OCR Verification', href: '/admin/workers/verifications', icon: ShieldCheck },
{ name: 'Safety & Moderation', href: '/admin/safety', icon: ShieldAlert }, { name: 'Safety & Moderation', href: '/admin/safety', icon: ShieldAlert },
{ name: 'Support Tickets', href: '/admin/tickets', icon: LifeBuoy }, { name: 'Support Tickets', href: '/admin/tickets', icon: LifeBuoy },
@ -143,15 +144,7 @@ export default function AdminLayout({ children, title = 'Dashboard' }) {
</div> </div>
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<button <div className="flex items-center space-x-3 pl-4">
type="button"
className="p-2 text-gray-500 hover:text-gray-800 rounded-full hover:bg-gray-50 relative focus:outline-none transition-colors"
>
<Bell className="w-5 h-5" />
<span className="absolute top-2 right-2 w-2 h-2 bg-[#0F6E56] rounded-full ring-2 ring-white" />
</button>
<div className="flex items-center space-x-3 border-l border-gray-100 pl-4">
<div className="w-8 h-8 rounded-full bg-[#0F6E56] text-white flex items-center justify-center font-medium text-xs shadow-sm"> <div className="w-8 h-8 rounded-full bg-[#0F6E56] text-white flex items-center justify-center font-medium text-xs shadow-sm">
{getInitials(user.name)} {getInitials(user.name)}
</div> </div>

View File

@ -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 (
<AdminLayout title="Charity Organizations">
<Head title="Charity Organizations" />
<div className="space-y-6 font-sans">
{/* Statistics Ribbons */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-5">
<div className="bg-white p-5 rounded-2xl border border-slate-200 shadow-sm flex items-center justify-between">
<div>
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest block mb-1">Total Charities</span>
<h3 className="text-2xl font-black text-slate-900">{stats.total || 0}</h3>
</div>
<div className="p-3 bg-teal-50 rounded-xl">
<Building className="w-6 h-6 text-teal-600" />
</div>
</div>
<div className="bg-white p-5 rounded-2xl border border-slate-200 shadow-sm flex items-center justify-between">
<div>
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest block mb-1">Verified</span>
<h3 className="text-2xl font-black text-emerald-600">
{stats.verified || 0}
</h3>
</div>
<div className="p-3 bg-emerald-50 rounded-xl">
<CheckCircle className="w-6 h-6 text-emerald-600" />
</div>
</div>
<div className="bg-white p-5 rounded-2xl border border-slate-200 shadow-sm flex items-center justify-between">
<div>
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest block mb-1">Suspended</span>
<h3 className="text-2xl font-black text-rose-600">
{stats.suspended || 0}
</h3>
</div>
<div className="p-3 bg-rose-50 rounded-xl">
<ShieldAlert className="w-6 h-6 text-rose-600" />
</div>
</div>
</div>
{/* Search and Filters */}
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4">
<div className="flex flex-1 flex-col sm:flex-row sm:items-center gap-3">
<div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type="text"
placeholder="Search by name, organization, or email..."
className="w-full pl-10 pr-4 py-2.5 bg-white border border-slate-200 rounded-xl text-sm font-medium focus:ring-4 focus:ring-teal-500/10 outline-none transition-all"
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
/>
</div>
<div className="flex flex-wrap items-center gap-2">
<select
className="px-4 py-2.5 bg-white border border-slate-200 rounded-xl text-xs font-bold text-slate-600 outline-none focus:ring-2 focus:ring-teal-500/10 transition-all cursor-pointer"
value={statusFilter}
onChange={e => handleFilterChange(e.target.value, verificationFilter, licenseFilter, nationalityFilter)}
>
<option value="All">All Statuses</option>
<option value="Active">Active</option>
<option value="Suspended">Suspended</option>
</select>
<select
className="px-4 py-2.5 bg-white border border-slate-200 rounded-xl text-xs font-bold text-slate-600 outline-none focus:ring-2 focus:ring-teal-500/10 transition-all cursor-pointer"
value={verificationFilter}
onChange={e => handleFilterChange(statusFilter, e.target.value, licenseFilter, nationalityFilter)}
>
<option value="All">All Verification</option>
<option value="Verified">Verified</option>
<option value="Unverified">Unverified</option>
</select>
<select
className="px-4 py-2.5 bg-white border border-slate-200 rounded-xl text-xs font-bold text-slate-600 outline-none focus:ring-2 focus:ring-teal-500/10 transition-all cursor-pointer"
value={licenseFilter}
onChange={e => handleFilterChange(statusFilter, verificationFilter, e.target.value, nationalityFilter)}
>
<option value="All">All License Status</option>
<option value="Valid">Valid</option>
<option value="Expired">Expired</option>
<option value="Expiring Soon">Expiring Soon</option>
</select>
<select
className="px-4 py-2.5 bg-white border border-slate-200 rounded-xl text-xs font-bold text-slate-600 outline-none focus:ring-2 focus:ring-teal-500/10 transition-all cursor-pointer"
value={nationalityFilter}
onChange={e => handleFilterChange(statusFilter, verificationFilter, licenseFilter, e.target.value)}
>
<option value="All">All Nationalities</option>
{nationalities.map(nat => (
<option key={nat} value={nat}>{nat}</option>
))}
</select>
</div>
</div>
<div className="flex items-center space-x-2 flex-shrink-0">
<button
onClick={handleExport}
className="px-4 py-2.5 bg-[#0F6E56] text-white rounded-xl text-xs font-black uppercase tracking-wider flex items-center gap-2 hover:bg-[#0c5945] transition-all shadow-md shadow-teal-900/10 cursor-pointer"
>
<Download className="w-4 h-4" />
<span>Export Charities</span>
</button>
</div>
</div>
{/* Table */}
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden">
<Table>
<TableHeader>
<TableRow className="bg-slate-50/50 hover:bg-slate-50/50">
<TableHead onClick={() => 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' ? '↓' : '↑')}
</TableHead>
<TableHead onClick={() => 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' ? '↓' : '↑')}
</TableHead>
<TableHead onClick={() => handleSort('city')} className="font-bold text-slate-500 text-[10px] uppercase tracking-widest h-14 cursor-pointer select-none">
Location {sortField === 'city' && (sortOrder === 'desc' ? '↓' : '↑')}
</TableHead>
<TableHead className="font-bold text-slate-500 text-[10px] uppercase tracking-widest h-14">Verification</TableHead>
<TableHead className="font-bold text-slate-500 text-[10px] uppercase tracking-widest h-14">License Expiry</TableHead>
<TableHead className="font-bold text-slate-500 text-[10px] uppercase tracking-widest h-14">Status</TableHead>
<TableHead className="font-bold text-slate-500 text-[10px] uppercase tracking-widest h-14 text-right pr-8">Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{sponsors.data.length > 0 ? (
sponsors.data.map((sponsor) => (
<TableRow key={sponsor.id} className="hover:bg-slate-50/50 transition-colors">
<TableCell className="py-5 pl-8 font-bold text-slate-900">
<div className="flex items-center space-x-3">
<div className="p-2.5 bg-teal-50 rounded-lg">
<Building className="w-4 h-4 text-teal-600" />
</div>
<div>
<span className="text-sm block">{sponsor.organization_name || 'N/A'}</span>
<span className="text-[10px] text-slate-400 font-bold uppercase tracking-wider">ID: ORG-{sponsor.id}</span>
</div>
</div>
</TableCell>
<TableCell>
<div className="text-xs font-bold text-slate-700">
<p>{sponsor.full_name}</p>
<p className="text-[10px] text-slate-400">{sponsor.email}</p>
</div>
</TableCell>
<TableCell>
<div className="flex items-center space-x-2 text-slate-600">
<MapPin className="w-3.5 h-3.5 text-slate-400" />
<span className="text-xs font-bold">{sponsor.city || 'Dubai'}</span>
</div>
</TableCell>
<TableCell>
<span className={`px-2 py-0.5 rounded-lg text-[10px] font-black uppercase tracking-tight ${
sponsor.is_verified ? 'bg-emerald-50 text-emerald-600 border border-emerald-100' : 'bg-amber-50 text-amber-600 border border-amber-100'
}`}>
{sponsor.is_verified ? 'Verified' : 'Pending'}
</span>
</TableCell>
<TableCell>
<div className="text-xs font-bold text-slate-700">
{sponsor.license_expiry ? new Date(sponsor.license_expiry).toLocaleDateString() : 'N/A'}
</div>
</TableCell>
<TableCell>
<div className="flex items-center space-x-2">
<span className={`text-[10px] font-black uppercase tracking-widest ${
sponsor.status === 'active' ? 'text-emerald-600' :
sponsor.status === 'inactive' ? 'text-amber-600' : 'text-rose-600'
}`}>
{sponsor.status}
</span>
</div>
</TableCell>
<TableCell className="text-right pr-8">
<div className="flex items-center justify-end space-x-1">
<button
onClick={() => viewDetails(sponsor)}
className="p-2 hover:bg-slate-100 rounded-lg text-[#0F6E56] font-bold"
>
<ExternalLink className="w-4.5 h-4.5" />
</button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button className="p-2 text-slate-400 hover:text-slate-900 hover:bg-slate-50 rounded-lg transition-all outline-none">
<MoreVertical className="w-5 h-5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-48 p-2 rounded-xl">
<DropdownMenuLabel className="text-[9px] font-black text-slate-400 uppercase tracking-widest px-2 py-2">Quick Actions</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
className="p-3 rounded-lg focus:bg-blue-50 cursor-pointer text-blue-600 font-bold"
onClick={() => handleEditClick(sponsor)}
>
<div className="flex items-center space-x-3">
<Edit2 className="w-4 h-4" />
<span className="text-xs font-bold">Edit Details</span>
</div>
</DropdownMenuItem>
{sponsor.status === 'active' ? (
<DropdownMenuItem
onClick={() => handleSuspend(sponsor.id)}
className="p-3 rounded-lg focus:bg-rose-50 cursor-pointer text-rose-600 font-bold"
>
<div className="flex items-center space-x-3">
<XCircle className="w-4 h-4" />
<span className="text-xs font-bold">Suspend Org</span>
</div>
</DropdownMenuItem>
) : (
<DropdownMenuItem
onClick={() => handleActivate(sponsor.id)}
className="p-3 rounded-lg focus:bg-emerald-50 cursor-pointer text-emerald-600 font-bold"
>
<div className="flex items-center space-x-3">
<CheckCircle className="w-4 h-4" />
<span className="text-xs font-bold">Activate Org</span>
</div>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={() => handleDelete(sponsor.id)}
className="p-3 rounded-lg focus:bg-red-50 cursor-pointer text-red-600 font-bold"
>
<div className="flex items-center space-x-3">
<Trash2 className="w-4 h-4" />
<span className="text-xs font-bold">Delete Org</span>
</div>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</TableCell>
</TableRow>
))
) : (
<TableRow>
<TableCell colSpan={7} className="py-12 text-center text-slate-400 font-semibold text-sm">
No charity organizations found.
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
{/* Pagination */}
<div className="p-5 border-t border-slate-100 flex items-center justify-between bg-slate-50/50">
<span className="text-xs text-slate-500 font-bold">
{sponsors.total > 0 ? (
`Showing ${sponsors.from || 0} to ${sponsors.to || 0} of ${sponsors.total} organizations`
) : (
'No charity organizations found'
)}
</span>
{sponsors.last_page > 1 && (
<div className="flex items-center space-x-2">
<Link
href={sponsors.prev_page_url || '#'}
only={['sponsors']}
preserveScroll
className={`px-4 py-2 bg-white border border-slate-200 rounded-xl text-xs font-bold text-slate-700 shadow-xs hover:bg-slate-50 transition-colors ${
!sponsors.prev_page_url && 'opacity-50 pointer-events-none'
}`}
>
Previous
</Link>
<Link
href={sponsors.next_page_url || '#'}
only={['sponsors']}
preserveScroll
className={`px-4 py-2 bg-white border border-slate-200 rounded-xl text-xs font-bold text-slate-700 shadow-xs hover:bg-slate-50 transition-colors ${
!sponsors.next_page_url && 'opacity-50 pointer-events-none'
}`}
>
Next
</Link>
</div>
)}
</div>
</div>
</div>
{/* Details Modal */}
<Dialog open={isDetailsOpen} onOpenChange={setIsDetailsOpen}>
<DialogContent className="sm:max-w-2xl w-full bg-white p-0 overflow-hidden rounded-[24px] max-h-[85vh] flex flex-col">
<div className="bg-gradient-to-r from-[#0F6E56] to-[#085041] p-6 text-white flex-shrink-0">
<div className="flex items-center space-x-4">
<div className="w-14 h-14 rounded-2xl bg-white/10 backdrop-blur-md flex items-center justify-center border border-white/20 font-black text-xl">
{selectedSponsor?.organization_name?.charAt(0) || 'C'}
</div>
<div>
<h2 className="text-xl font-black tracking-tight">{selectedSponsor?.organization_name || 'Charity Details'}</h2>
<p className="text-teal-100/80 text-xs font-semibold mt-1">📍 {selectedSponsor?.city || 'Dubai'} Registered {selectedSponsor?.created_at ? new Date(selectedSponsor.created_at).toLocaleDateString() : ''}</p>
</div>
</div>
</div>
<div className="p-6 space-y-6 overflow-y-auto flex-1 bg-slate-50/50">
<div className="bg-white p-5 border border-slate-200 rounded-2xl shadow-sm space-y-4">
<h3 className="text-xs font-black text-slate-700 uppercase tracking-widest border-b border-slate-100 pb-2">Organization Details</h3>
<div className="grid grid-cols-2 gap-4 text-xs font-bold text-slate-700">
<div><span className="text-slate-400 font-medium block">Representative</span>{selectedSponsor?.full_name}</div>
<div><span className="text-slate-400 font-medium block">Email Address</span>{selectedSponsor?.email}</div>
<div><span className="text-slate-400 font-medium block">Mobile Number</span>{selectedSponsor?.mobile}</div>
<div><span className="text-slate-400 font-medium block">Nationality</span>{selectedSponsor?.nationality || 'N/A'}</div>
<div><span className="text-slate-400 font-medium block">City / Location</span>{selectedSponsor?.city || 'N/A'}</div>
<div><span className="text-slate-400 font-medium block">License Expiry</span>{selectedSponsor?.license_expiry ? new Date(selectedSponsor.license_expiry).toLocaleDateString() : 'N/A'}</div>
<div><span className="text-slate-400 font-medium block">Status</span><span className="capitalize">{selectedSponsor?.status}</span></div>
<div><span className="text-slate-400 font-medium block">Verification</span>{selectedSponsor?.is_verified ? 'Verified' : 'Pending Verification'}</div>
{selectedSponsor?.address && <div className="col-span-2"><span className="text-slate-400 font-medium block">Physical Address</span>{selectedSponsor?.address}</div>}
</div>
</div>
<div className="bg-white p-5 border border-slate-200 rounded-2xl shadow-sm space-y-4">
<h3 className="text-xs font-black text-slate-700 uppercase tracking-widest border-b border-slate-100 pb-2">Uploaded Documents</h3>
<div className="grid grid-cols-2 gap-4 text-xs font-bold text-slate-700">
{selectedSponsor?.license_file ? (
<div>
<span className="text-slate-400 font-medium block mb-1">Trade License</span>
<a href={`/${selectedSponsor.license_file}`} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-teal-600 hover:text-teal-700 hover:underline">
View Trade License <ExternalLink className="w-3 h-3" />
</a>
</div>
) : (
<div>
<span className="text-slate-400 font-medium block mb-1">Trade License</span>
<span className="text-slate-400 font-medium">Not Uploaded</span>
</div>
)}
{selectedSponsor?.emirates_id_file ? (
<div>
<span className="text-slate-400 font-medium block mb-1">Emirates ID Document</span>
<a href={`/${selectedSponsor.emirates_id_file}`} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1 text-teal-600 hover:text-teal-700 hover:underline">
View Emirates ID <ExternalLink className="w-3 h-3" />
</a>
</div>
) : (
<div>
<span className="text-slate-400 font-medium block mb-1">Emirates ID Document</span>
<span className="text-slate-400 font-medium">Not Uploaded</span>
</div>
)}
</div>
</div>
</div>
<div className="p-6 border-t border-slate-100 bg-slate-50 flex items-center justify-end gap-3 flex-shrink-0">
{selectedSponsor && !selectedSponsor.is_verified && (
<button
onClick={() => handleApprove(selectedSponsor.id)}
className="px-5 py-2.5 bg-emerald-600 text-white rounded-xl text-[10px] font-black uppercase tracking-widest hover:bg-emerald-700 transition-colors flex items-center gap-1.5 cursor-pointer"
>
<CheckCircle className="w-3.5 h-3.5" />
Verify & Activate
</button>
)}
{selectedSponsor && selectedSponsor.is_verified && selectedSponsor.status === 'active' && (
<button
onClick={() => handleSuspend(selectedSponsor.id)}
className="px-5 py-2.5 bg-rose-600 text-white rounded-xl text-[10px] font-black uppercase tracking-widest hover:bg-rose-700 transition-colors flex items-center gap-1.5 cursor-pointer"
>
<XCircle className="w-3.5 h-3.5" />
Suspend Organization
</button>
)}
{selectedSponsor && selectedSponsor.is_verified && selectedSponsor.status === 'suspended' && (
<button
onClick={() => handleActivate(selectedSponsor.id)}
className="px-5 py-2.5 bg-emerald-600 text-white rounded-xl text-[10px] font-black uppercase tracking-widest hover:bg-emerald-700 transition-colors flex items-center gap-1.5 cursor-pointer"
>
<CheckCircle className="w-3.5 h-3.5" />
Activate Organization
</button>
)}
<button
onClick={() => setIsDetailsOpen(false)}
className="px-5 py-2.5 bg-slate-200 text-slate-700 rounded-xl text-[10px] font-black uppercase tracking-widest hover:bg-slate-300 transition-colors cursor-pointer"
>
Close
</button>
</div>
</DialogContent>
</Dialog>
{/* Edit Dialog */}
<Dialog open={isEditOpen} onOpenChange={setIsEditOpen}>
<DialogContent className="sm:max-w-xl w-full bg-white p-6 rounded-[24px]">
<DialogHeader>
<DialogTitle className="text-lg font-black text-slate-800 uppercase tracking-wide">Edit Charity Details</DialogTitle>
</DialogHeader>
<form onSubmit={handleEditSubmit} className="space-y-4 mt-4">
<div className="space-y-1">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Organization Name</label>
<input
type="text"
className="w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm font-semibold focus:ring-4 focus:ring-teal-500/10 outline-none"
value={editForm.organization_name}
onChange={e => setEditForm({ ...editForm, organization_name: e.target.value })}
required
/>
</div>
<div className="space-y-1">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Representative Full Name</label>
<input
type="text"
className="w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm font-semibold focus:ring-4 focus:ring-teal-500/10 outline-none"
value={editForm.full_name}
onChange={e => setEditForm({ ...editForm, full_name: e.target.value })}
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Email Address</label>
<input
type="email"
className="w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm font-semibold focus:ring-4 focus:ring-teal-500/10 outline-none"
value={editForm.email}
onChange={e => setEditForm({ ...editForm, email: e.target.value })}
required
/>
</div>
<div className="space-y-1">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Mobile Number</label>
<input
type="text"
className="w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm font-semibold focus:ring-4 focus:ring-teal-500/10 outline-none"
value={editForm.mobile}
onChange={e => setEditForm({ ...editForm, mobile: e.target.value })}
required
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">City</label>
<select
className="w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm font-semibold focus:ring-4 focus:ring-teal-500/10 outline-none"
value={editForm.city}
onChange={e => setEditForm({ ...editForm, city: e.target.value })}
>
<option value="Dubai">Dubai</option>
<option value="Abu Dhabi">Abu Dhabi</option>
<option value="Sharjah">Sharjah</option>
</select>
</div>
<div className="space-y-1">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Nationality</label>
<input
type="text"
className="w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm font-semibold focus:ring-4 focus:ring-teal-500/10 outline-none"
value={editForm.nationality}
onChange={e => setEditForm({ ...editForm, nationality: e.target.value })}
/>
</div>
</div>
<div className="space-y-1">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Address</label>
<input
type="text"
className="w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm font-semibold focus:ring-4 focus:ring-teal-500/10 outline-none"
value={editForm.address}
onChange={e => setEditForm({ ...editForm, address: e.target.value })}
/>
</div>
<div className="flex items-center justify-end space-x-3 pt-4 border-t border-slate-100">
<button
type="button"
onClick={() => setIsEditOpen(false)}
className="px-4 py-2 bg-slate-100 text-slate-600 rounded-xl text-xs font-bold hover:bg-slate-200"
>
Cancel
</button>
<button
type="submit"
className="px-5 py-2.5 bg-[#0F6E56] text-white rounded-xl text-xs font-black uppercase tracking-wider"
>
Save Changes
</button>
</div>
</form>
</DialogContent>
</Dialog>
</AdminLayout>
);
}

View File

@ -23,7 +23,8 @@ import {
Trash2, Trash2,
Check, Check,
Phone, Phone,
ShieldCheck ShieldCheck,
Eye
} from 'lucide-react'; } from 'lucide-react';
import { import {
Table, Table,
@ -49,26 +50,37 @@ import {
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Badge } from '@/components/ui/badge'; 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 [searchTerm, setSearchTerm] = useState(filters.search || '');
const [statusFilter, setStatusFilter] = useState(filters.status || 'All'); const [statusFilter, setStatusFilter] = useState(filters.status || 'All');
const [locationFilter, setLocationFilter] = useState(filters.location || 'All'); const [locationFilter, setLocationFilter] = useState(filters.location || 'All');
const [sortField, setSortField] = useState(filters.sort_field || 'created_at'); const [sortField, setSortField] = useState(filters.sort_field || 'created_at');
const [sortOrder, setSortOrder] = useState(filters.sort_order || 'desc'); 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 [isDetailsOpen, setIsDetailsOpen] = useState(false);
const [isEditOpen, setIsEditOpen] = useState(false); const [isEditOpen, setIsEditOpen] = useState(false);
const [editForm, setEditForm] = useState({ const [editForm, setEditForm] = useState({
id: '', id: '',
full_name: '', name: '',
email: '', email: '',
mobile: '', company_name: '',
city: '', phone: '',
nationality: '', country: '',
address: '', subscription_status: 'none'
subscription_plan: 'premium',
subscription_status: 'active'
}); });
// Handle search/filter queries // Handle search/filter queries
@ -134,22 +146,20 @@ export default function SponsorsIndex({ sponsors, filters = {} }) {
}; };
const handleDelete = (id) => { 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}`); router.delete(`/admin/employers/${id}`);
} }
}; };
const handleEditClick = (sponsor) => { const handleEditClick = (employer) => {
setEditForm({ setEditForm({
id: sponsor.id, id: employer.id,
full_name: sponsor.full_name, name: employer.name,
email: sponsor.email, email: employer.email,
mobile: sponsor.mobile, company_name: employer.employer_profile?.company_name || '',
city: sponsor.city || 'Dubai', phone: employer.employer_profile?.phone || '',
nationality: sponsor.nationality || '', country: employer.employer_profile?.country || 'UAE',
address: sponsor.address || '', subscription_status: employer.subscription_status || 'none'
subscription_plan: sponsor.subscription_plan || 'premium',
subscription_status: sponsor.subscription_status || 'active'
}); });
setIsEditOpen(true); setIsEditOpen(true);
}; };
@ -174,8 +184,8 @@ export default function SponsorsIndex({ sponsors, filters = {} }) {
window.location.href = `/admin/employers/export?${params.toString()}`; window.location.href = `/admin/employers/export?${params.toString()}`;
}; };
const viewDetails = (sponsor) => { const viewDetails = (employer) => {
setSelectedSponsor(sponsor); setSelectedEmployer(employer);
setIsDetailsOpen(true); setIsDetailsOpen(true);
}; };
@ -189,7 +199,7 @@ export default function SponsorsIndex({ sponsors, filters = {} }) {
<div className="bg-white p-5 rounded-2xl border border-slate-200 shadow-sm flex items-center justify-between"> <div className="bg-white p-5 rounded-2xl border border-slate-200 shadow-sm flex items-center justify-between">
<div> <div>
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest block mb-1">Total Employers</span> <span className="text-[10px] font-black text-slate-400 uppercase tracking-widest block mb-1">Total Employers</span>
<h3 className="text-2xl font-black text-slate-900">{sponsors.total || 0}</h3> <h3 className="text-2xl font-black text-slate-900">{employers.total || 0}</h3>
</div> </div>
<div className="p-3 bg-teal-50 rounded-xl"> <div className="p-3 bg-teal-50 rounded-xl">
<Users className="w-6 h-6 text-teal-600" /> <Users className="w-6 h-6 text-teal-600" />
@ -199,7 +209,7 @@ export default function SponsorsIndex({ sponsors, filters = {} }) {
<div> <div>
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest block mb-1">Active Plans</span> <span className="text-[10px] font-black text-slate-400 uppercase tracking-widest block mb-1">Active Plans</span>
<h3 className="text-2xl font-black text-blue-600"> <h3 className="text-2xl font-black text-blue-600">
{sponsors.data.filter(s => s.subscription_status === 'active').length} {employers.data.filter(e => e.subscription_status === 'active').length}
</h3> </h3>
</div> </div>
<div className="p-3 bg-blue-50 rounded-xl"> <div className="p-3 bg-blue-50 rounded-xl">
@ -210,7 +220,7 @@ export default function SponsorsIndex({ sponsors, filters = {} }) {
<div> <div>
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest block mb-1">Pending Approval</span> <span className="text-[10px] font-black text-slate-400 uppercase tracking-widest block mb-1">Pending Approval</span>
<h3 className="text-2xl font-black text-amber-500"> <h3 className="text-2xl font-black text-amber-500">
{sponsors.data.filter(s => !s.is_verified).length} {employers.data.filter(e => e.employer_profile?.verification_status !== 'approved').length}
</h3> </h3>
</div> </div>
<div className="p-3 bg-amber-50 rounded-xl"> <div className="p-3 bg-amber-50 rounded-xl">
@ -221,7 +231,7 @@ export default function SponsorsIndex({ sponsors, filters = {} }) {
<div> <div>
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest block mb-1">Suspended Accounts</span> <span className="text-[10px] font-black text-slate-400 uppercase tracking-widest block mb-1">Suspended Accounts</span>
<h3 className="text-2xl font-black text-rose-600"> <h3 className="text-2xl font-black text-rose-600">
{sponsors.data.filter(s => s.status === 'suspended').length} {employers.data.filter(e => e.status === 'suspended').length}
</h3> </h3>
</div> </div>
<div className="p-3 bg-rose-50 rounded-xl"> <div className="p-3 bg-rose-50 rounded-xl">
@ -237,7 +247,7 @@ export default function SponsorsIndex({ sponsors, filters = {} }) {
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input <input
type="text" type="text"
placeholder="Search by name, email, or mobile..." placeholder="Search by name, company, email..."
className="w-full pl-10 pr-4 py-2.5 bg-white border border-slate-200 rounded-xl text-sm font-medium focus:ring-4 focus:ring-teal-500/10 outline-none transition-all" className="w-full pl-10 pr-4 py-2.5 bg-white border border-slate-200 rounded-xl text-sm font-medium focus:ring-4 focus:ring-teal-500/10 outline-none transition-all"
value={searchTerm} value={searchTerm}
onChange={e => setSearchTerm(e.target.value)} onChange={e => setSearchTerm(e.target.value)}
@ -252,20 +262,8 @@ export default function SponsorsIndex({ sponsors, filters = {} }) {
> >
<option value="All">All Statuses</option> <option value="All">All Statuses</option>
<option value="Active">Active</option> <option value="Active">Active</option>
<option value="Inactive">Inactive</option>
<option value="Suspended">Suspended</option> <option value="Suspended">Suspended</option>
</select> </select>
<select
className="px-4 py-2.5 bg-white border border-slate-200 rounded-xl text-xs font-bold text-slate-600 outline-none focus:ring-2 focus:ring-teal-500/10 transition-all cursor-pointer"
value={locationFilter}
onChange={e => handleFilterChange(statusFilter, e.target.value)}
>
<option value="All">All Locations</option>
<option value="Dubai">Dubai</option>
<option value="Abu Dhabi">Abu Dhabi</option>
<option value="Sharjah">Sharjah</option>
</select>
</div> </div>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
@ -279,17 +277,15 @@ export default function SponsorsIndex({ sponsors, filters = {} }) {
</div> </div>
</div> </div>
{/* Dynamic Employers Table */} {/* Table */}
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden"> <div className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow className="bg-slate-50/50 hover:bg-slate-50/50"> <TableRow className="bg-slate-50/50 hover:bg-slate-50/50">
<TableHead onClick={() => handleSort('full_name')} className="font-bold text-slate-500 text-[10px] uppercase tracking-widest h-14 pl-8 cursor-pointer select-none"> <TableHead onClick={() => handleSort('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' ? '↓' : '↑')} Employer {sortField === 'name' && (sortOrder === 'desc' ? '↓' : '↑')}
</TableHead>
<TableHead onClick={() => handleSort('city')} className="font-bold text-slate-500 text-[10px] uppercase tracking-widest h-14 cursor-pointer select-none">
Location {sortField === 'city' && (sortOrder === 'desc' ? '↓' : '↑')}
</TableHead> </TableHead>
<TableHead className="font-bold text-slate-500 text-[10px] uppercase tracking-widest h-14">Company Name</TableHead>
<TableHead className="font-bold text-slate-500 text-[10px] uppercase tracking-widest h-14">Subscription Plan</TableHead> <TableHead className="font-bold text-slate-500 text-[10px] uppercase tracking-widest h-14">Subscription Plan</TableHead>
<TableHead className="font-bold text-slate-500 text-[10px] uppercase tracking-widest h-14">Verification Status</TableHead> <TableHead className="font-bold text-slate-500 text-[10px] uppercase tracking-widest h-14">Verification Status</TableHead>
<TableHead onClick={() => handleSort('status')} className="font-bold text-slate-500 text-[10px] uppercase tracking-widest h-14 cursor-pointer select-none"> <TableHead onClick={() => 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 = {} }) {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{sponsors.data.length > 0 ? ( {employers.data.length > 0 ? (
sponsors.data.map((sponsor) => ( employers.data.map((emp) => (
<TableRow key={sponsor.id} className="hover:bg-slate-50/50 transition-colors"> <TableRow key={emp.id} className="hover:bg-slate-50/50 transition-colors">
<TableCell className="py-5 pl-8"> <TableCell className="py-5 pl-8">
<div className="flex items-center space-x-4"> <div className="flex items-center space-x-4">
<div className="w-10 h-10 rounded-xl bg-[#0F6E56]/10 text-[#0F6E56] flex items-center justify-center border border-[#0F6E56]/20 font-bold text-sm"> <div className="w-10 h-10 rounded-xl bg-[#0F6E56]/10 text-[#0F6E56] flex items-center justify-center border border-[#0F6E56]/20 font-bold text-sm">
{sponsor.full_name.charAt(0)} {emp.name?.charAt(0)}
</div> </div>
<div> <div>
<h4 className="text-sm font-bold text-slate-900 leading-none mb-1">{sponsor.full_name}</h4> <h4 className="text-sm font-bold text-slate-900 leading-none mb-1">{emp.name}</h4>
<p className="text-xs text-slate-500 font-medium">{sponsor.email}</p> <p className="text-xs text-slate-500 font-medium">{emp.email}</p>
<p className="text-[10px] text-slate-400 font-medium">{sponsor.mobile}</p>
</div> </div>
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="flex items-center space-x-2 text-slate-600"> <div className="text-xs font-bold text-slate-700">
<MapPin className="w-3.5 h-3.5 text-slate-400" /> {emp.employer_profile?.company_name || 'N/A'}
<span className="text-xs font-bold">{sponsor.city || 'Dubai'}</span>
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="flex flex-col space-y-1"> <span className={`px-2 py-0.5 rounded-lg text-[10px] font-black uppercase tracking-tight ${
<span className={`px-2 py-0.5 rounded-lg text-[10px] font-black uppercase tracking-tight w-max ${ emp.subscription_status === 'active' ? 'bg-blue-50 text-blue-600 border border-blue-100' : 'bg-slate-100 text-slate-500'
sponsor.subscription_plan ? 'bg-blue-50 text-blue-600 border border-blue-100' : 'bg-slate-100 text-slate-500' }`}>
}`}> {emp.subscription_status || 'none'}
{sponsor.subscription_plan ? `${sponsor.subscription_plan} Pass` : 'No Plan'} </span>
</span>
<span className={`text-[9px] font-bold ${
sponsor.subscription_status === 'active' ? 'text-emerald-600' : 'text-slate-400'
}`}>
Status: {sponsor.subscription_status.toUpperCase()}
</span>
</div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="flex items-center space-x-1.5"> <span className={`text-xs font-bold uppercase ${
{sponsor.is_verified ? ( emp.employer_profile?.verification_status === 'approved' ? 'text-emerald-600' : 'text-amber-500'
<> }`}>
<ShieldCheck className="w-4 h-4 text-emerald-500" /> {emp.employer_profile?.verification_status || 'pending'}
<span className="text-xs font-bold text-emerald-600">OTP Verified</span> </span>
</>
) : (
<>
<Clock className="w-4 h-4 text-amber-500" />
<span className="text-xs font-bold text-amber-600">Unverified</span>
</>
)}
</div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="flex items-center space-x-2"> <span className={`text-[10px] font-black uppercase tracking-widest ${
{sponsor.status === 'active' && <CheckCircle className="w-4 h-4 text-emerald-500" />} emp.status === 'active' ? 'text-emerald-600' : 'text-rose-600'
{sponsor.status === 'inactive' && <Clock className="w-4 h-4 text-amber-500" />} }`}>
{sponsor.status === 'suspended' && <XCircle className="w-4 h-4 text-rose-500" />} {emp.status}
<span className={`text-[10px] font-black uppercase tracking-widest ${ </span>
sponsor.status === 'active' ? 'text-emerald-600' :
sponsor.status === 'inactive' ? 'text-amber-600' : 'text-rose-600'
}`}>
{sponsor.status}
</span>
</div>
</TableCell> </TableCell>
<TableCell className="text-right pr-8"> <TableCell className="text-right pr-8">
<div className="flex items-center justify-end space-x-1"> <div className="flex items-center justify-end space-x-1">
<button <button
onClick={() => viewDetails(sponsor)} onClick={() => viewDetails(emp)}
className="p-2 hover:bg-slate-100 rounded-lg text-[#0F6E56] font-bold" className="p-2 hover:bg-slate-100 rounded-lg text-[#0F6E56] font-bold"
title="View Details" title="View Details"
> >
<ExternalLink className="w-4.5 h-4.5" /> <Eye className="w-4.5 h-4.5" />
</button> </button>
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger asChild> <DropdownMenuTrigger asChild>
@ -382,24 +355,24 @@ export default function SponsorsIndex({ sponsors, filters = {} }) {
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem
className="p-3 rounded-lg focus:bg-blue-50 cursor-pointer text-blue-600 font-bold" className="p-3 rounded-lg focus:bg-blue-50 cursor-pointer text-blue-600 font-bold"
onClick={() => handleEditClick(sponsor)} onClick={() => handleEditClick(emp)}
> >
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<Edit2 className="w-4 h-4" /> <Edit2 className="w-4 h-4" />
<span className="text-xs font-bold">Edit Employer</span> <span className="text-xs font-bold">Edit Employer</span>
</div> </div>
</DropdownMenuItem> </DropdownMenuItem>
{!sponsor.is_verified && ( {emp.employer_profile?.verification_status !== 'approved' && (
<DropdownMenuItem className="p-3 rounded-lg focus:bg-emerald-50 cursor-pointer text-emerald-600 font-bold" onClick={() => handleApprove(sponsor.id)}> <DropdownMenuItem className="p-3 rounded-lg focus:bg-emerald-50 cursor-pointer text-emerald-600 font-bold" onClick={() => handleApprove(emp.id)}>
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<CheckCircle className="w-4 h-4" /> <CheckCircle className="w-4 h-4" />
<span className="text-xs font-bold">Verify Employer</span> <span className="text-xs font-bold">Verify Employer</span>
</div> </div>
</DropdownMenuItem> </DropdownMenuItem>
)} )}
{sponsor.status === 'active' ? ( {emp.status === 'active' ? (
<DropdownMenuItem <DropdownMenuItem
onClick={() => handleSuspend(sponsor.id)} onClick={() => handleSuspend(emp.id)}
className="p-3 rounded-lg focus:bg-rose-50 cursor-pointer text-rose-600 font-bold" className="p-3 rounded-lg focus:bg-rose-50 cursor-pointer text-rose-600 font-bold"
> >
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
@ -409,7 +382,7 @@ export default function SponsorsIndex({ sponsors, filters = {} }) {
</DropdownMenuItem> </DropdownMenuItem>
) : ( ) : (
<DropdownMenuItem <DropdownMenuItem
onClick={() => handleActivate(sponsor.id)} onClick={() => handleActivate(emp.id)}
className="p-3 rounded-lg focus:bg-emerald-50 cursor-pointer text-emerald-600 font-bold" className="p-3 rounded-lg focus:bg-emerald-50 cursor-pointer text-emerald-600 font-bold"
> >
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
@ -420,8 +393,8 @@ export default function SponsorsIndex({ sponsors, filters = {} }) {
)} )}
<DropdownMenuSeparator /> <DropdownMenuSeparator />
<DropdownMenuItem <DropdownMenuItem
onClick={() => handleDelete(sponsor.id)} onClick={() => handleDelete(emp.id)}
className="p-3 rounded-lg focus:bg-red-50 cursor-pointer text-red-600 font-bold animate-pulse" className="p-3 rounded-lg focus:bg-red-50 cursor-pointer text-red-600 font-bold"
> >
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<Trash2 className="w-4 h-4" /> <Trash2 className="w-4 h-4" />
@ -437,7 +410,7 @@ export default function SponsorsIndex({ sponsors, filters = {} }) {
) : ( ) : (
<TableRow> <TableRow>
<TableCell colSpan={6} className="py-12 text-center text-slate-400 font-semibold text-sm"> <TableCell colSpan={6} className="py-12 text-center text-slate-400 font-semibold text-sm">
No employers match the active search/filters criteria. No employers found.
</TableCell> </TableCell>
</TableRow> </TableRow>
)} )}
@ -447,30 +420,30 @@ export default function SponsorsIndex({ sponsors, filters = {} }) {
{/* Pagination */} {/* Pagination */}
<div className="p-5 border-t border-slate-100 flex items-center justify-between bg-slate-50/50"> <div className="p-5 border-t border-slate-100 flex items-center justify-between bg-slate-50/50">
<span className="text-xs text-slate-500 font-bold"> <span className="text-xs text-slate-500 font-bold">
{sponsors.total > 0 ? ( {employers.total > 0 ? (
`Showing ${sponsors.from || 0} to ${sponsors.to || 0} of ${sponsors.total} employers` `Showing ${employers.from || 0} to ${employers.to || 0} of ${employers.total} employers`
) : ( ) : (
'No employers found' 'No employers found'
)} )}
</span> </span>
{sponsors.last_page > 1 && ( {employers.last_page > 1 && (
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Link <Link
href={sponsors.prev_page_url || '#'} href={employers.prev_page_url || '#'}
only={['sponsors']} only={['employers']}
preserveScroll preserveScroll
className={`px-4 py-2 bg-white border border-slate-200 rounded-xl text-xs font-bold text-slate-700 shadow-xs hover:bg-slate-50 transition-colors ${ className={`px-4 py-2 bg-white border border-slate-200 rounded-xl text-xs font-bold text-slate-700 shadow-xs hover:bg-slate-50 transition-colors ${
!sponsors.prev_page_url && 'opacity-50 pointer-events-none' !employers.prev_page_url && 'opacity-50 pointer-events-none'
}`} }`}
> >
Previous Previous
</Link> </Link>
<Link <Link
href={sponsors.next_page_url || '#'} href={employers.next_page_url || '#'}
only={['sponsors']} only={['employers']}
preserveScroll preserveScroll
className={`px-4 py-2 bg-white border border-slate-200 rounded-xl text-xs font-bold text-slate-700 shadow-xs hover:bg-slate-50 transition-colors ${ className={`px-4 py-2 bg-white border border-slate-200 rounded-xl text-xs font-bold text-slate-700 shadow-xs hover:bg-slate-50 transition-colors ${
!sponsors.next_page_url && 'opacity-50 pointer-events-none' !employers.next_page_url && 'opacity-50 pointer-events-none'
}`} }`}
> >
Next Next
@ -481,142 +454,127 @@ export default function SponsorsIndex({ sponsors, filters = {} }) {
</div> </div>
</div> </div>
{/* Advanced Employer Detail Modal */} {/* Employer Detail Modal */}
<Dialog open={isDetailsOpen} onOpenChange={setIsDetailsOpen}> <Dialog open={isDetailsOpen} onOpenChange={setIsDetailsOpen}>
<DialogContent className="sm:max-w-4xl w-full bg-white p-0 overflow-hidden rounded-[24px] border-none shadow-2xl font-sans max-h-[85vh] flex flex-col"> <DialogContent className="sm:max-w-xl w-full bg-white p-0 overflow-hidden rounded-[24px] max-h-[85vh] flex flex-col">
<div className="bg-gradient-to-r from-[#0F6E56] to-[#085041] p-6 text-white relative flex-shrink-0"> <div className="bg-gradient-to-r from-[#0F6E56] to-[#085041] p-6 text-white flex-shrink-0">
<div className="relative z-10 flex items-center space-x-4"> <div className="flex items-center space-x-4">
<div className="w-14 h-14 rounded-2xl bg-white/10 backdrop-blur-md flex items-center justify-center border border-white/20 font-black text-xl"> <div className="w-14 h-14 rounded-2xl bg-white/10 backdrop-blur-md flex items-center justify-center border border-white/20 font-black text-xl">
{selectedSponsor?.full_name.charAt(0)} {selectedEmployer?.name?.charAt(0)}
</div> </div>
<div> <div>
<h2 className="text-2xl font-black tracking-tight">{selectedSponsor?.full_name}</h2> <h2 className="text-xl font-black tracking-tight">{selectedEmployer?.name}</h2>
<p className="text-teal-100/80 text-xs font-semibold mt-1">📍 {selectedSponsor?.city || 'Dubai'} Registered {new Date(selectedSponsor?.created_at).toLocaleDateString()}</p> <p className="text-teal-100/80 text-xs font-semibold mt-1">📍 Registered {new Date(selectedEmployer?.created_at).toLocaleDateString()}</p>
</div> </div>
</div> </div>
</div> </div>
<div className="p-6 space-y-6 overflow-y-auto flex-1 bg-slate-50/50"> <div className="p-6 space-y-6 overflow-y-auto flex-1 bg-slate-50/50">
{/* Verification info */} {/* Employer Info Card */}
<div className="bg-white p-5 border border-slate-200 rounded-2xl shadow-sm space-y-4"> <div className="bg-white p-5 border border-slate-200 rounded-2xl shadow-xs space-y-4">
<div className="flex items-center justify-between border-b border-slate-100 pb-2"> <h3 className="text-xs font-black text-slate-700 uppercase tracking-widest border-b border-slate-100 pb-2 flex items-center gap-2">
<h3 className="text-xs font-black text-slate-700 uppercase tracking-widest flex items-center gap-1.5"> <Users className="w-4 h-4 text-[#0F6E56]" />
<Award className="w-4.5 h-4.5 text-[#0F6E56]" /> Employer Profile
<span>UAE Employer Details</span> </h3>
</h3> <div className="grid grid-cols-2 gap-4 text-xs">
<Badge className="bg-teal-50 text-[#0F6E56] border-none font-black text-[9px] uppercase px-3 py-1 rounded-lg"> <div>
Employer ID: EMP-{selectedSponsor?.id} <span className="text-[10px] font-black text-slate-400 uppercase tracking-wider block mb-0.5">Company / Household</span>
</Badge> <span className="font-bold text-slate-800">{selectedEmployer?.employer_profile?.company_name || 'N/A'}</span>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div className="text-xs font-bold text-slate-600 space-y-3">
<div className="flex items-center gap-2 text-slate-800">
<Mail className="w-4 h-4 text-slate-400" />
<span>Email: {selectedSponsor?.email}</span>
</div>
<div className="flex items-center gap-2 text-slate-800">
<Phone className="w-4 h-4 text-slate-400" />
<span>Mobile: {selectedSponsor?.mobile}</span>
</div>
{selectedSponsor?.address && (
<div className="flex items-center gap-2 text-slate-800">
<MapPin className="w-4 h-4 text-slate-400" />
<span>Address: {selectedSponsor?.address}</span>
</div>
)}
</div> </div>
<div className="text-xs font-bold text-slate-600 space-y-3"> <div>
<div>OTP Verification: {selectedSponsor?.is_verified ? 'Passed' : 'Pending'}</div> <span className="text-[10px] font-black text-slate-400 uppercase tracking-wider block mb-0.5">Email Address</span>
{selectedSponsor?.otp_verified_at && ( <span className="font-bold text-slate-800">{selectedEmployer?.email || 'N/A'}</span>
<div>Verified At: {new Date(selectedSponsor.otp_verified_at).toLocaleString()}</div> </div>
)} <div>
<div>Nationality: {selectedSponsor?.nationality || 'N/A'}</div> <span className="text-[10px] font-black text-slate-400 uppercase tracking-wider block mb-0.5">Phone Number</span>
<span className="font-bold text-slate-800">{selectedEmployer?.employer_profile?.phone || 'N/A'}</span>
</div>
<div>
<span className="text-[10px] font-black text-slate-400 uppercase tracking-wider block mb-0.5">Country / Location</span>
<span className="font-bold text-slate-800">{selectedEmployer?.employer_profile?.country || 'United Arab Emirates'}</span>
</div> </div>
</div> </div>
</div> </div>
{/* Subscription monitoring */} {/* Subscription Plan Card */}
<div className="bg-white p-5 border border-slate-200 rounded-2xl shadow-sm space-y-3"> <div className="bg-white p-5 border border-slate-200 rounded-2xl shadow-xs space-y-4">
<h3 className="text-xs font-black text-slate-700 uppercase tracking-widest flex items-center gap-1.5 border-b border-slate-100 pb-2"> <h3 className="text-xs font-black text-slate-700 uppercase tracking-widest border-b border-slate-100 pb-2 flex items-center gap-2">
<BadgeDollarSign className="w-4.5 h-4.5 text-blue-600" /> <BadgeDollarSign className="w-4 h-4 text-[#0F6E56]" />
<span>Premium Subscription Status</span> Chosen Subscription Plan
</h3> </h3>
<div className="grid grid-cols-2 gap-4 text-xs font-bold text-slate-700"> <div className="grid grid-cols-2 gap-4 text-xs">
<div> <div>
<span className="text-[9px] text-slate-400 uppercase tracking-widest block font-black mb-1">Active Plan</span> <span className="text-[10px] font-black text-slate-400 uppercase tracking-wider block mb-0.5">Current Plan</span>
<span className="text-sm font-black text-blue-700 bg-blue-50 px-2.5 py-1 rounded-lg border border-blue-100/50 w-fit block capitalize"> <span className={`inline-block px-2 py-0.5 rounded-md text-[10px] font-black uppercase tracking-tight ${
{selectedSponsor?.subscription_plan || 'No Active Plan'} selectedEmployer?.subscription_status === 'active'
? 'bg-blue-50 text-blue-600 border border-blue-100'
: 'bg-slate-100 text-slate-500'
}`}>
{selectedEmployer?.subscription_status === 'active'
? (selectedEmployer?.sponsor?.subscription_plan || 'Active Premium Plan')
: 'No Active Plan'}
</span> </span>
</div> </div>
<div> <div>
<span className="text-[9px] text-slate-400 uppercase tracking-widest block font-black mb-1">Payment Status</span> <span className="text-[10px] font-black text-slate-400 uppercase tracking-wider block mb-0.5">Payment Status</span>
<span className="inline-flex items-center text-slate-800 bg-slate-100 px-2.5 py-1 rounded-lg border border-slate-200/50 capitalize"> <span className="font-bold text-slate-800 uppercase">{selectedEmployer?.sponsor?.payment_status || 'N/A'}</span>
{selectedSponsor?.payment_status} </div>
<div>
<span className="text-[10px] font-black text-slate-400 uppercase tracking-wider block mb-0.5">Plan Starts</span>
<span className="font-bold text-slate-800">{formatDate(selectedEmployer?.sponsor?.subscription_start_date)}</span>
</div>
<div>
<span className="text-[10px] font-black text-slate-400 uppercase tracking-wider block mb-0.5">Plan Expires</span>
<span className="font-bold text-slate-800">{formatDate(selectedEmployer?.sponsor?.subscription_end_date)}</span>
</div>
</div>
</div>
{/* Document Verification & OCR Extracted Details */}
<div className="bg-white p-5 border border-slate-200 rounded-2xl shadow-xs space-y-4">
<h3 className="text-xs font-black text-slate-700 uppercase tracking-widest border-b border-slate-100 pb-2 flex items-center gap-2">
<ShieldCheck className="w-4 h-4 text-[#0F6E56]" />
Emirates ID & Document Extracted Details
</h3>
<div className="grid grid-cols-2 gap-4 text-xs">
<div>
<span className="text-[10px] font-black text-slate-400 uppercase tracking-wider block mb-0.5">Emirates ID Number</span>
<span className="font-mono font-bold text-slate-800">{selectedEmployer?.employer_profile?.emirates_id_number || 'Not Extracted / Pending'}</span>
</div>
<div>
<span className="text-[10px] font-black text-slate-400 uppercase tracking-wider block mb-0.5">Emirates ID Expiry</span>
<span className="font-bold text-slate-800">{formatDate(selectedEmployer?.employer_profile?.emirates_id_expiry)}</span>
</div>
<div>
<span className="text-[10px] font-black text-slate-400 uppercase tracking-wider block mb-0.5">Verification Status</span>
<span className={`font-bold uppercase ${
selectedEmployer?.employer_profile?.verification_status === 'approved' ? 'text-emerald-600' : 'text-amber-500'
}`}>
{selectedEmployer?.employer_profile?.verification_status || 'Pending'}
</span> </span>
</div> </div>
{selectedSponsor?.subscription_start_date && ( <div>
<div> <span className="text-[10px] font-black text-slate-400 uppercase tracking-wider block mb-0.5">Trade License Expiry</span>
<span className="text-[9px] text-slate-400 uppercase tracking-widest block font-black mb-1">Start Date</span> <span className="font-bold text-slate-800">{formatDate(selectedEmployer?.sponsor?.license_expiry)}</span>
<span className="font-mono text-slate-600">{new Date(selectedSponsor.subscription_start_date).toLocaleDateString()}</span> </div>
</div>
)}
{selectedSponsor?.subscription_end_date && (
<div>
<span className="text-[9px] text-slate-400 uppercase tracking-widest block font-black mb-1">Expiry Date</span>
<span className="font-mono text-slate-600">{new Date(selectedSponsor.subscription_end_date).toLocaleDateString()}</span>
</div>
)}
</div> </div>
</div> </div>
</div> </div>
<div className="p-6 border-t border-slate-100 bg-slate-50 flex items-center justify-between flex-shrink-0"> <div className="p-6 border-t border-slate-100 bg-slate-50 flex items-center justify-end gap-3 flex-shrink-0">
<div className="flex items-center space-x-2"> <button
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Verification Action:</span> onClick={() => setIsDetailsOpen(false)}
{!selectedSponsor?.is_verified ? ( className="px-5 py-2.5 bg-slate-200 text-slate-700 rounded-xl text-[10px] font-black uppercase tracking-widest hover:bg-slate-300 transition-colors"
<button >
onClick={() => handleApprove(selectedSponsor.id)} Close
className="px-4 py-2.5 bg-emerald-600 text-white rounded-xl text-[10px] font-black uppercase tracking-widest shadow-md shadow-emerald-600/10 hover:bg-emerald-500 transition-colors" </button>
>
Verify Employer OTP
</button>
) : (
<Badge className="bg-emerald-100 text-emerald-800 border-none font-black uppercase text-[9px] px-3 py-1.5 rounded-lg shadow-sm">
OTP Verified
</Badge>
)}
</div>
<div className="flex items-center space-x-3">
{selectedSponsor?.status !== 'suspended' ? (
<button
onClick={() => handleSuspend(selectedSponsor.id)}
className="px-4 py-2.5 bg-red-600 text-white rounded-xl text-[10px] font-black uppercase tracking-widest shadow-md shadow-red-600/10 hover:bg-red-700 transition-all"
>
Suspend Employer
</button>
) : (
<button
onClick={() => handleActivate(selectedSponsor.id)}
className="px-4 py-2.5 bg-slate-900 text-white rounded-xl text-[10px] font-black uppercase tracking-widest hover:bg-slate-800 transition-colors"
>
Activate Employer
</button>
)}
<button
onClick={() => setIsDetailsOpen(false)}
className="px-5 py-2.5 bg-slate-200 text-slate-700 rounded-xl text-[10px] font-black uppercase tracking-widest hover:bg-slate-300 transition-colors"
>
Close Details
</button>
</div>
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Employer Edit Dialog */} {/* Edit Dialog */}
<Dialog open={isEditOpen} onOpenChange={setIsEditOpen}> <Dialog open={isEditOpen} onOpenChange={setIsEditOpen}>
<DialogContent className="sm:max-w-xl w-full bg-white p-6 rounded-[24px] font-sans"> <DialogContent className="sm:max-w-xl w-full bg-white p-6 rounded-[24px]">
<DialogHeader> <DialogHeader>
<DialogTitle className="text-lg font-black text-slate-800 uppercase tracking-wide">Edit Employer Details</DialogTitle> <DialogTitle className="text-lg font-black text-slate-800 uppercase tracking-wide">Edit Employer Details</DialogTitle>
</DialogHeader> </DialogHeader>
@ -626,11 +584,20 @@ export default function SponsorsIndex({ sponsors, filters = {} }) {
<input <input
type="text" type="text"
className="w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm font-semibold focus:ring-4 focus:ring-teal-500/10 outline-none" className="w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm font-semibold focus:ring-4 focus:ring-teal-500/10 outline-none"
value={editForm.full_name} value={editForm.name}
onChange={e => setEditForm({ ...editForm, full_name: e.target.value })} onChange={e => setEditForm({ ...editForm, name: e.target.value })}
required required
/> />
</div> </div>
<div className="space-y-1">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Company Name</label>
<input
type="text"
className="w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm font-semibold focus:ring-4 focus:ring-teal-500/10 outline-none"
value={editForm.company_name}
onChange={e => setEditForm({ ...editForm, company_name: e.target.value })}
/>
</div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-1"> <div className="space-y-1">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Email Address</label> <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Email Address</label>
@ -643,75 +610,15 @@ export default function SponsorsIndex({ sponsors, filters = {} }) {
/> />
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Mobile Number</label> <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Phone Number</label>
<input <input
type="text" type="text"
className="w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm font-semibold focus:ring-4 focus:ring-teal-500/10 outline-none" className="w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm font-semibold focus:ring-4 focus:ring-teal-500/10 outline-none"
value={editForm.mobile} value={editForm.phone}
onChange={e => setEditForm({ ...editForm, mobile: e.target.value })} onChange={e => setEditForm({ ...editForm, phone: e.target.value })}
required
/> />
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-1">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">City</label>
<select
className="w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm font-semibold focus:ring-4 focus:ring-teal-500/10 outline-none"
value={editForm.city}
onChange={e => setEditForm({ ...editForm, city: e.target.value })}
>
<option value="Dubai">Dubai</option>
<option value="Abu Dhabi">Abu Dhabi</option>
<option value="Sharjah">Sharjah</option>
</select>
</div>
<div className="space-y-1">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Nationality</label>
<input
type="text"
className="w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm font-semibold focus:ring-4 focus:ring-teal-500/10 outline-none"
value={editForm.nationality}
onChange={e => setEditForm({ ...editForm, nationality: e.target.value })}
/>
</div>
</div>
<div className="space-y-1">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Address</label>
<input
type="text"
className="w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm font-semibold focus:ring-4 focus:ring-teal-500/10 outline-none"
value={editForm.address}
onChange={e => setEditForm({ ...editForm, address: e.target.value })}
/>
</div>
<div className="grid grid-cols-2 gap-4 border-t border-slate-100 pt-3">
<div className="space-y-1">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Subscription Plan</label>
<select
className="w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm font-semibold focus:ring-4 focus:ring-teal-500/10 outline-none"
value={editForm.subscription_plan}
onChange={e => setEditForm({ ...editForm, subscription_plan: e.target.value })}
>
<option value="basic">Basic Search</option>
<option value="premium">Premium Pass</option>
<option value="vip">VIP Concierge</option>
<option value="">No Active Plan</option>
</select>
</div>
<div className="space-y-1">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Subscription Status</label>
<select
className="w-full px-4 py-2.5 border border-slate-200 rounded-xl text-sm font-semibold focus:ring-4 focus:ring-teal-500/10 outline-none"
value={editForm.subscription_status}
onChange={e => setEditForm({ ...editForm, subscription_status: e.target.value })}
>
<option value="active">Active</option>
<option value="expired">Expired</option>
<option value="none">None</option>
</select>
</div>
</div>
<div className="flex items-center justify-end space-x-3 pt-4 border-t border-slate-100"> <div className="flex items-center justify-end space-x-3 pt-4 border-t border-slate-100">
<button <button
@ -725,7 +632,7 @@ export default function SponsorsIndex({ sponsors, filters = {} }) {
type="submit" type="submit"
className="px-5 py-2.5 bg-[#0F6E56] text-white rounded-xl text-xs font-black uppercase tracking-wider" className="px-5 py-2.5 bg-[#0F6E56] text-white rounded-xl text-xs font-black uppercase tracking-wider"
> >
Save Employer Save Changes
</button> </button>
</div> </div>
</form> </form>

View File

@ -14,8 +14,6 @@ import {
Settings, Settings,
FileText, FileText,
MessageSquare, MessageSquare,
Bell,
Calendar,
Search, Search,
ChevronDown, ChevronDown,
MoreVertical, MoreVertical,
@ -98,20 +96,6 @@ export default function SafetyHub({ reports, rules, moderation_queue }) {
<h1 className="text-2xl font-bold text-slate-900 tracking-tight">Safety & Moderation</h1> <h1 className="text-2xl font-bold text-slate-900 tracking-tight">Safety & Moderation</h1>
<p className="text-sm text-slate-500 mt-1">Review and take action on reported content, users and reviews.</p> <p className="text-sm text-slate-500 mt-1">Review and take action on reported content, users and reviews.</p>
</div> </div>
{/* Header Controls */}
<div className="flex items-center gap-3 self-end md:self-auto">
<button className="relative p-2.5 bg-white border border-slate-200 rounded-xl hover:bg-slate-50 transition-colors shadow-sm text-slate-600">
<Bell className="w-5 h-5" />
<span className="absolute top-1.5 right-1.5 w-4 h-4 bg-red-500 text-white rounded-full text-[9px] font-bold flex items-center justify-center">12</span>
</button>
<div className="flex items-center gap-2 px-4 py-2.5 bg-white border border-slate-200 rounded-xl hover:bg-slate-50 transition-colors shadow-sm text-xs font-semibold text-slate-700 cursor-pointer">
<Calendar className="w-4 h-4 text-slate-400" />
<span>May 13, 2025 - Jun 11, 2025</span>
<ChevronDown className="w-4 h-4 text-slate-400" />
</div>
</div>
</div> </div>
{/* Upper Row Metric Cards */} {/* Upper Row Metric Cards */}

View File

@ -33,13 +33,23 @@
Route::get('/workers/verifications', [\App\Http\Controllers\Admin\WorkerVerificationController::class, 'index'])->name('admin.workers.verifications'); 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::post('/workers/{worker}/verify', [\App\Http\Controllers\Admin\WorkerVerificationController::class, 'verify'])->name('admin.workers.verify');
Route::get('/employers', [SponsorController::class, 'index'])->name('admin.employers'); // Employer Management Routes
Route::get('/employers/export', [SponsorController::class, 'export'])->name('admin.employers.export'); Route::get('/employers', [\App\Http\Controllers\Admin\EmployerController::class, 'index'])->name('admin.employers');
Route::post('/employers/{id}/verify', [SponsorController::class, 'verify'])->name('admin.employers.verify'); Route::get('/employers/export', [\App\Http\Controllers\Admin\EmployerController::class, 'export'])->name('admin.employers.export');
Route::post('/employers/{id}/suspend', [SponsorController::class, 'suspend'])->name('admin.employers.suspend'); Route::post('/employers/{id}/verify', [\App\Http\Controllers\Admin\EmployerController::class, 'verify'])->name('admin.employers.verify');
Route::post('/employers/{id}/activate', [SponsorController::class, 'activate'])->name('admin.employers.activate'); Route::post('/employers/{id}/suspend', [\App\Http\Controllers\Admin\EmployerController::class, 'suspend'])->name('admin.employers.suspend');
Route::post('/employers/{id}/update', [SponsorController::class, 'update'])->name('admin.employers.update'); Route::post('/employers/{id}/activate', [\App\Http\Controllers\Admin\EmployerController::class, 'activate'])->name('admin.employers.activate');
Route::delete('/employers/{id}', [SponsorController::class, 'delete'])->name('admin.employers.delete'); 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 () { Route::get('/subscriptions', function () {
return Inertia::render('Admin/Subscriptions/Index'); return Inertia::render('Admin/Subscriptions/Index');

View File

@ -0,0 +1,238 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use App\Models\Sponsor;
use App\Models\EmployerProfile;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class AdminEmployerCharityTest extends TestCase
{
use RefreshDatabase;
protected $admin;
protected $employer;
protected $charity;
protected function setUp(): void
{
parent::setUp();
// 1. Create an Admin user
$this->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);
}
}

View File

@ -64,7 +64,8 @@ public function test_sponsor_can_register_with_license()
]); ]);
$sponsor = Sponsor::where('mobile', '+971509990001')->first(); $sponsor = Sponsor::where('mobile', '+971509990001')->first();
$this->assertNotNull($sponsor->emirates_id_file); $this->assertNull($sponsor->emirates_id_file);
$this->assertNull($sponsor->license_file);
} }
/** /**

View File

@ -105,6 +105,39 @@ public function test_s1_otp_flow()
$this->assertTrue(Cache::get('worker_otp_verified_' . $phone)); $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. * Test S1 Onboard: Complete Basic Profile Setup.
*/ */