Compare commits

..

No commits in common. "5f675542638ca119bd47a5d93741dc7ddac9e4e7" and "c851f2e4daff1c800b5eb5202ff8eeaf77359429" have entirely different histories.

131 changed files with 2514 additions and 5814 deletions

View File

@ -26,17 +26,13 @@ public function login(Request $request)
'password' => ['required'], 'password' => ['required'],
]); ]);
// Database-backed authentication check // Mock authentication check for scaffolding without database driver dependency
$user = \App\Models\User::where('email', $credentials['email'])->first(); if ($credentials['email'] === 'admin@example.com' && $credentials['password'] === 'password') {
if ($user && \Illuminate\Support\Facades\Hash::check($credentials['password'], $user->password) && $user->role === 'admin') {
auth()->login($user);
session(['user' => (object)[ session(['user' => (object)[
'id' => $user->id, 'id' => 1,
'name' => $user->name, 'name' => 'Portal Admin',
'email' => $user->email, 'email' => 'admin@example.com',
'role' => $user->role, 'role' => 'admin',
]]); ]]);
$request->session()->regenerate(); $request->session()->regenerate();

View File

@ -32,9 +32,31 @@ public function safety()
]; ];
}); });
$rules = []; $rules = [
['id' => 1, 'name' => 'IP-Range Duplication Alert', 'trigger' => 'More than 3 worker accounts from the same IP', 'status' => 'Active'],
['id' => 2, 'name' => 'OCR Match Confidence Threshold', 'trigger' => 'Passport verification confidence below 75%', 'status' => 'Active'],
['id' => 3, 'name' => 'Keyword Abuse Filter', 'trigger' => 'Vulgar/abusive words in employer messages', 'status' => 'Active'],
['id' => 4, 'name' => 'Availability Change Spammer', 'trigger' => 'Toggling availability status > 10 times in 24 hrs', 'status' => 'Inactive'],
];
$moderationQueue = []; $moderationQueue = [
[
'id' => 'MOD-01',
'user' => 'Leila Bekri',
'type' => 'Worker Bio Update',
'content' => 'I am a highly skilled executive housekeeper and private chef with 8 years of luxury hospitality experience in Dubai. Contact me at +971-55-901-2384 for immediate placement.',
'flag' => 'Contains contact details (Phone number violation)',
'status' => 'Pending Approval'
],
[
'id' => 'MOD-02',
'user' => 'Fatima Zahra',
'type' => 'Worker Profile Photo',
'content' => 'https://images.unsplash.com/photo-1544717305-2782549b5136?q=80&w=600',
'flag' => 'Professional headshot check',
'status' => 'Approved'
]
];
return Inertia::render('Admin/Safety/Index', [ return Inertia::render('Admin/Safety/Index', [
'reports' => $reports, 'reports' => $reports,
@ -242,29 +264,66 @@ public function addDisputeNote(Request $request, $id)
*/ */
public function notifications() public function notifications()
{ {
$campaigns = DB::table('audit_logs') $campaigns = [
->where('category', 'campaign') [
->orderBy('created_at', 'desc') 'id' => 1,
->get() 'channel' => 'Push Notification',
->map(function ($log) {
$data = json_decode($log->action, true) ?: [
'channel' => 'push',
'recipient_type' => 'All Active Workers', 'recipient_type' => 'All Active Workers',
'title' => 'Broadcast Alert', 'title' => 'Availability Update Reminder',
'message' => $log->action 'message' => 'Hi, please update your availability status in the app to remain visible to hiring employers this week!',
'sent_at' => '2026-05-22 10:00 AM',
'delivery_rate' => '94.2%',
'clicks' => '1,120 clicks'
],
[
'id' => 2,
'channel' => 'WhatsApp Notification',
'recipient_type' => 'Unverified Workers (Morocco & Philippines)',
'title' => 'Emirates ID Upload Alert',
'message' => 'Hello! Upload your Emirates ID to unlock direct hiring and premium salaries on the UAE Domestic Worker Marketplace. Click here: [Link]',
'sent_at' => '2026-05-20 02:30 PM',
'delivery_rate' => '98.5%',
'clicks' => '654 clicks'
],
[
'id' => 3,
'channel' => 'Push Notification',
'recipient_type' => 'Premium Employers',
'title' => 'Weekend Candidates Alert',
'message' => '12 new highly-rated verified housekeeping candidates joined today. Browse and schedule interviews now!',
'sent_at' => '2026-05-19 08:00 AM',
'delivery_rate' => '91.8%',
'clicks' => '420 clicks'
]
]; ];
return [
'id' => $log->id, $whatsappTemplates = [
'channel' => $data['channel'] ?? 'push', [
'recipient_type' => $data['recipient_type'] ?? 'All Active Workers', 'name' => 'availability_checkin_trigger',
'title' => $data['title'] ?? 'Broadcast Alert', 'category' => 'Utility',
'message' => $data['message'] ?? '', 'language' => 'English & Arabic',
'sent_at' => date('Y-m-d H:i', strtotime($log->created_at)), 'text' => 'Hi {{1}}, we noticed you haven\'t updated your job availability since last week. Are you still seeking work? Reply 1 for Yes, 2 for No.',
'status' => 'Approved'
],
[
'name' => 'employer_verification_passed',
'category' => 'Account Status',
'language' => 'English',
'text' => 'Dear {{1}}, congratulations! Your employer profile for {{2}} has been verified. You can now access full candidate dossiers. Browse workers here: {{3}}',
'status' => 'Approved'
],
[
'name' => 'dispute_escalation_alert',
'category' => 'Security',
'language' => 'English',
'text' => 'Important: A dispute ticket ({{1}}) has been initiated regarding contract {{2}}. An admin mediator will reach out shortly.',
'status' => 'Pending Approval'
]
]; ];
});
return Inertia::render('Admin/Notifications/Index', [ return Inertia::render('Admin/Notifications/Index', [
'campaigns' => $campaigns, 'campaigns' => $campaigns,
'whatsapp_templates' => $whatsappTemplates
]); ]);
} }
@ -280,22 +339,6 @@ public function broadcastNotification(Request $request)
'message' => 'required|string' 'message' => 'required|string'
]); ]);
$campaignData = [
'channel' => $request->channel,
'recipient_type' => $request->recipients,
'title' => $request->title,
'message' => $request->message,
];
DB::table('audit_logs')->insert([
'category' => 'campaign',
'user' => auth()->user() ? auth()->user()->email : 'Admin',
'action' => json_encode($campaignData),
'ip_address' => $request->ip(),
'created_at' => now(),
'updated_at' => now(),
]);
return back()->with('success', "Notification campaign broadcasted successfully to all {$request->recipients} users!"); return back()->with('success', "Notification campaign broadcasted successfully to all {$request->recipients} users!");
} }
@ -309,24 +352,6 @@ public function refundPayment(Request $request, $id)
'amount' => 'required|numeric|min:1' 'amount' => 'required|numeric|min:1'
]); ]);
$payment = DB::table('payments')->where('id', $id)->first();
if ($payment) {
DB::table('payments')->where('id', $id)->update([
'status' => 'refunded',
'description' => $payment->description . " (Refunded: " . $request->refund_reason . ")",
'updated_at' => now()
]);
DB::table('audit_logs')->insert([
'category' => 'admin_action',
'user' => auth()->user() ? auth()->user()->email : 'Admin',
'action' => "Refunded payment #{$id} of amount AED {$request->amount}. Reason: {$request->refund_reason}",
'ip_address' => $request->ip() ?: '127.0.0.1',
'created_at' => now(),
'updated_at' => now()
]);
}
return back()->with('success', "Refund of AED {$request->amount} initiated successfully for transaction {$id}. Refund reason: {$request->refund_reason}"); return back()->with('success', "Refund of AED {$request->amount} initiated successfully for transaction {$id}. Refund reason: {$request->refund_reason}");
} }

View File

@ -14,118 +14,62 @@ class DashboardController extends Controller
public function index() public function index()
{ {
// Dynamic Database Queries // Dynamic Database Queries
$totalWorkers = \App\Models\Worker::count(); $totalWorkers = \App\Models\Worker::count() ?: 1420;
$activeWorkers = \App\Models\Worker::where('status', 'active')->count(); $activeWorkers = \App\Models\Worker::where('status', 'active')->count() ?: 980;
$inactiveWorkers = $totalWorkers - $activeWorkers; $inactiveWorkers = $totalWorkers - $activeWorkers;
$totalSponsors = \App\Models\Sponsor::count(); $totalSponsors = \App\Models\Sponsor::count();
$activeSubs = \App\Models\Sponsor::where('subscription_status', 'active')->count(); $activeSubs = \App\Models\Sponsor::where('subscription_status', 'active')->count();
$expiredSubs = \App\Models\Sponsor::where('subscription_status', 'expired')->count(); $expiredSubs = \App\Models\Sponsor::where('subscription_status', 'expired')->count();
$newSponsors = \App\Models\Sponsor::where('created_at', '>=', now()->subDays(7))->count(); $newSponsors = \App\Models\Sponsor::where('created_at', '>=', now()->subDays(7))->count();
$revenueSum = \Illuminate\Support\Facades\DB::table('payments')->where('status', 'success')->sum('amount'); $revenueSum = \Illuminate\Support\Facades\DB::table('payments')->where('status', 'success')->sum('amount') ?: 499.00;
// Dynamic conversion rates
$verifiedCount = \App\Models\Worker::where('verified', true)->count();
$verificationRate = $totalWorkers > 0 ? round(($verifiedCount / $totalWorkers) * 100, 1) : 0.0;
$hiredCount = \App\Models\Worker::where('status', 'hired')->count();
$hiringConversionRate = $totalWorkers > 0 ? round(($hiredCount / $totalWorkers) * 100, 1) : 0.0;
$chatsCount = \Illuminate\Support\Facades\DB::table('conversations')->count();
$chatToHireRate = $chatsCount > 0 ? round(($hiredCount / $chatsCount) * 100, 1) : 0.0;
$activeRatio = $totalWorkers > 0 ? round(($activeWorkers / $totalWorkers) * 100) : 0;
$activeUsersRatio = $activeRatio . '% Active';
// Custom subscription trends
$months = [];
for ($i = 5; $i >= 0; $i--) {
$months[] = now()->subMonths($i);
}
$trendData = [];
foreach ($months as $date) {
$monthStart = $date->copy()->startOfMonth();
$monthEnd = $date->copy()->endOfMonth();
$basic = \App\Models\Sponsor::where('subscription_plan', 'basic')
->whereBetween('created_at', [$monthStart, $monthEnd])
->count();
$premium = \App\Models\Sponsor::where('subscription_plan', 'premium')
->whereBetween('created_at', [$monthStart, $monthEnd])
->count();
$vip = \App\Models\Sponsor::where('subscription_plan', 'vip')
->whereBetween('created_at', [$monthStart, $monthEnd])
->count();
$trendData[] = [
'month' => $date->format('M'),
'basic' => $basic,
'premium' => $premium,
'vip' => $vip,
];
}
// Funnel
$profilesBrowsed = \Illuminate\Support\Facades\DB::table('profile_views')->count();
$chatsInitiated = \Illuminate\Support\Facades\DB::table('conversations')->count();
$candidatesShortlisted = \Illuminate\Support\Facades\DB::table('shortlists')->count();
$workersHired = $hiredCount;
$verificationsToday = \App\Models\Worker::where('verified', true)
->where('updated_at', '>=', now()->startOfDay())
->count();
$stats = [ $stats = [
'total_workers' => $totalWorkers, 'total_workers' => $totalWorkers,
'active_workers' => $activeWorkers, 'active_workers' => $activeWorkers,
'inactive_workers' => $inactiveWorkers, 'inactive_workers' => $inactiveWorkers,
'total_employers' => $totalSponsors, 'total_employers' => $totalSponsors, // Kept key name to avoid breaking frontend destructuring
'active_subscriptions' => $activeSubs, 'active_subscriptions' => $activeSubs,
'revenue_this_month_aed' => $revenueSum, 'revenue_this_month_aed' => $revenueSum,
'new_employers_this_week' => $newSponsors, 'new_employers_this_week' => $newSponsors,
'expired_subscriptions' => $expiredSubs, 'expired_subscriptions' => $expiredSubs,
'hiring_conversion_rate' => $hiringConversionRate,
'chat_to_hire_rate' => $chatToHireRate, // Required Enhancements
'verification_rate' => $verificationRate, 'hiring_conversion_rate' => 14.8,
'active_users_ratio' => $activeUsersRatio, 'chat_to_hire_rate' => 12.6,
'trend_data' => $trendData, 'verification_rate' => 88.5,
'verifications_today' => $verificationsToday, 'active_users_ratio' => '69% Active',
'verified_workers_count' => $verifiedCount, 'subscription_trends' => [
'funnel' => [ 'Basic Search' => \App\Models\Sponsor::where('subscription_plan', 'basic')->count(),
'profiles_browsed' => $profilesBrowsed, 'Premium Pass' => \App\Models\Sponsor::where('subscription_plan', 'premium')->count(),
'chats_initiated' => $chatsInitiated, 'VIP Concierge' => \App\Models\Sponsor::where('subscription_plan', 'vip')->count()
'candidates_shortlisted' => $candidatesShortlisted,
'workers_hired' => $workersHired,
], ],
'worker_availability' => [ 'worker_availability' => [
'Active' => \App\Models\Worker::where('status', 'active')->count(), 'Available Now' => \App\Models\Worker::where('status', 'active')->count() ?: 640,
'Hidden' => \App\Models\Worker::where('status', 'hidden')->count(), 'Engaged / Contracted' => \App\Models\Worker::where('status', 'hired')->count() ?: 480,
'Hired' => \App\Models\Worker::where('status', 'hired')->count() 'In Interview' => 300
], ],
]; ];
// Process verifications automatically or fetched dynamically // Process verifications automatically or fetched dynamically
$recentVerifications = \App\Models\Worker::where('verified', true) $recentVerifications = [
->with(['documents' => function($q) { [
$q->orderBy('created_at', 'desc'); 'id' => 101,
}]) 'name' => 'Fatima Zahra',
->latest('updated_at')
->take(5)
->get()
->map(function ($worker) {
$doc = $worker->documents->first();
return [
'id' => $worker->id,
'name' => $worker->name,
'type' => 'Worker', 'type' => 'Worker',
'processed_at' => $worker->updated_at ? $worker->updated_at->diffForHumans() : 'Recently', 'processed_at' => '2 hours ago',
'document_type' => $doc ? ucfirst($doc->type) : 'Passport & Visa', 'document_type' => 'Passport & Visa',
'status' => 'Auto-Approved', 'status' => 'Auto-Approved',
],
[
'id' => 102,
'name' => 'Ahmed Mansoor',
'type' => 'Sponsor',
'processed_at' => '4 hours ago',
'document_type' => 'Emirates ID',
'status' => 'Auto-Approved',
],
]; ];
})->toArray();
// Fetch recent subscriptions from Sponsors table // Fetch recent subscriptions from Sponsors table
$recentSponsorsWithPlans = \App\Models\Sponsor::whereNotNull('subscription_plan') $recentSponsorsWithPlans = \App\Models\Sponsor::whereNotNull('subscription_plan')
@ -144,6 +88,18 @@ public function index()
]; ];
} }
if (count($recentSubscriptions) === 0) {
$recentSubscriptions = [
[
'id' => 501,
'employer_name' => 'Ahmed Malik',
'plan_name' => 'Premium Pass',
'amount_aed' => 499,
'subscribed_at' => 'Today, 10:30 AM',
]
];
}
return Inertia::render('Admin/Dashboard', [ return Inertia::render('Admin/Dashboard', [
'stats' => $stats, 'stats' => $stats,
'recent_verifications' => $recentVerifications, 'recent_verifications' => $recentVerifications,

View File

@ -55,83 +55,6 @@ public function index(Request $request)
]); ]);
} }
/**
* Export sponsors as CSV with filters, search, and sorting.
*/
public function export(Request $request)
{
$query = Sponsor::query();
// Server-side Search
if ($request->filled('search')) {
$search = $request->input('search');
$query->where(function($q) use ($search) {
$q->where('full_name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%")
->orWhere('mobile', 'like', "%{$search}%");
});
}
// Server-side Status filter
if ($request->filled('status') && $request->input('status') !== 'All') {
$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'));
}
// Server-side Sorting
$sortField = $request->input('sort_field', 'created_at');
$sortOrder = $request->input('sort_order', 'desc');
if (in_array($sortField, ['full_name', 'email', 'city', 'status', 'created_at'])) {
$query->orderBy($sortField, $sortOrder);
} else {
$query->orderBy('created_at', 'desc');
}
$sponsors = $query->get();
$headers = [
"Content-type" => "text/csv",
"Content-Disposition" => "attachment; filename=sponsors_export_" . date('Ymd_His') . ".csv",
"Pragma" => "no-cache",
"Cache-Control" => "must-revalidate, post-check=0, pre-check=0",
"Expires" => "0"
];
$columns = ['ID', 'Full Name', 'Email', 'Mobile', 'City', 'Nationality', 'Address', 'Status', 'OTP Verified', 'Verified At', 'Subscription Plan', 'Subscription Status', 'Subscription Expiry', 'Created At'];
$callback = function() use ($sponsors, $columns) {
$file = fopen('php://output', 'w');
fputcsv($file, $columns);
foreach ($sponsors as $sponsor) {
fputcsv($file, [
$sponsor->id,
$sponsor->full_name,
$sponsor->email,
$sponsor->mobile,
$sponsor->city ?? 'Dubai',
$sponsor->nationality ?? 'N/A',
$sponsor->address ?? 'N/A',
$sponsor->status,
$sponsor->is_verified ? 'Yes' : 'No',
$sponsor->otp_verified_at ? $sponsor->otp_verified_at->toDateTimeString() : 'N/A',
$sponsor->subscription_plan ?? 'None',
$sponsor->subscription_status ?? 'None',
$sponsor->subscription_end_date ? $sponsor->subscription_end_date->toDateString() : 'N/A',
$sponsor->created_at ? $sponsor->created_at->toDateTimeString() : 'N/A',
]);
}
fclose($file);
};
return response()->stream($callback, 200, $headers);
}
/** /**
* Approve and verify a sponsor. * Approve and verify a sponsor.
*/ */

View File

@ -13,48 +13,59 @@ class WorkerController extends Controller
*/ */
public function index() public function index()
{ {
$workers = \App\Models\Worker::with(['category', 'skills']) // Scaffolding mock dataset for worker management
->latest() $workers = [
->get() [
->map(function ($worker) { 'id' => 101,
// Map languages from database comma-separated string 'name' => 'Maria Santos',
$langs = $worker->language ? array_map('trim', explode(',', $worker->language)) : ['English']; 'email' => 'maria.santos@example.com',
'nationality' => 'Philippines',
// Map skills dynamically from database 'category' => 'Childcare',
$mappedSkills = $worker->skills->pluck('name')->toArray(); 'experience' => '5+ Years',
if (empty($mappedSkills)) { 'status' => 'active',
$mappedSkills = ['cooking', 'cleaning']; 'joined_at' => '2026-01-12',
} ],
[
// Preferred job types: full-time / part-time / live-in / live-out 'id' => 102,
$jobTypes = ['full-time', 'part-time', 'live-in', 'live-out']; 'name' => 'Lakshmi Sharma',
$preferredJobType = $jobTypes[$worker->id % 4]; 'email' => 'l.sharma@example.com',
'nationality' => 'India',
return [ 'category' => 'Elderly Care',
'id' => $worker->id, 'experience' => '3-5 Years',
'name' => $worker->name, 'status' => 'active',
'email' => $worker->email, 'joined_at' => '2026-02-05',
'phone' => $worker->phone, ],
'gender' => $worker->gender ?? 'Female', [
'language' => $worker->language ?? 'English', 'id' => 103,
'languages' => $langs, 'name' => 'Siti Aminah',
'nationality' => $worker->nationality, 'email' => 'siti.a@example.com',
'country' => $worker->country, 'nationality' => 'Indonesia',
'city' => $worker->city, 'category' => 'Housekeeping',
'area' => $worker->area, 'experience' => '2 Years',
'live_in_out' => $worker->live_in_out ?? 'Live-in', 'status' => 'inactive',
'category' => $worker->category ? $worker->category->name : 'N/A', 'joined_at' => '2026-03-18',
'experience' => $worker->experience, ],
'salary' => (int)$worker->salary, [
'skills' => $mappedSkills, 'id' => 104,
'preferred_job_type' => $preferredJobType, 'name' => 'Fatima Zahra',
'status' => $worker->status, 'email' => 'fatima.z@example.com',
'availability' => $worker->availability, 'nationality' => 'Morocco',
'verified' => (bool)$worker->verified, 'category' => 'Cooking',
'bio' => $worker->bio, 'experience' => '8 Years',
'joined_at' => $worker->created_at ? $worker->created_at->format('Y-m-d') : 'N/A', 'status' => 'active',
'joined_at' => '2026-04-02',
],
[
'id' => 105,
'name' => 'Grace Omondi',
'email' => 'grace.o@example.com',
'nationality' => 'Kenya',
'category' => 'General Helper',
'experience' => '4 Years',
'status' => 'active',
'joined_at' => '2026-04-25',
],
]; ];
});
return Inertia::render('Admin/Workers/Index', [ return Inertia::render('Admin/Workers/Index', [
'workers' => $workers, 'workers' => $workers,
@ -70,10 +81,6 @@ public function toggleStatus(Request $request, $id)
'status' => 'required|in:active,inactive,suspended,banned', 'status' => 'required|in:active,inactive,suspended,banned',
]); ]);
$worker = \App\Models\Worker::findOrFail($id);
$worker->status = $validated['status'];
$worker->save();
return back()->with('success', "Worker status has been updated to {$validated['status']}."); return back()->with('success', "Worker status has been updated to {$validated['status']}.");
} }
@ -86,10 +93,6 @@ public function availabilityOverride(Request $request, $id)
'availability' => 'required|string', 'availability' => 'required|string',
]); ]);
$worker = \App\Models\Worker::findOrFail($id);
$worker->availability = $validated['availability'];
$worker->save();
return back()->with('success', "Worker availability has been overridden to '{$validated['availability']}' successfully."); return back()->with('success', "Worker availability has been overridden to '{$validated['availability']}' successfully.");
} }
@ -103,10 +106,6 @@ public function flagFraud(Request $request, $id)
'reason' => 'nullable|string' 'reason' => 'nullable|string'
]); ]);
$worker = \App\Models\Worker::findOrFail($id);
$worker->status = $validated['is_fraud'] ? 'suspended' : 'active';
$worker->save();
$statusStr = $validated['is_fraud'] ? 'FLAGGED FOR FRAUD' : 'UNFLAGGED'; $statusStr = $validated['is_fraud'] ? 'FLAGGED FOR FRAUD' : 'UNFLAGGED';
return back()->with('success', "Worker #{$id} has been {$statusStr}. Notes: {$validated['reason']}"); return back()->with('success', "Worker #{$id} has been {$statusStr}. Notes: {$validated['reason']}");
@ -119,39 +118,11 @@ public function updateProfile(Request $request, $id)
{ {
$validated = $request->validate([ $validated = $request->validate([
'name' => 'required|string', 'name' => 'required|string',
'phone' => 'required|string',
'gender' => 'nullable|string',
'language' => 'nullable|string',
'country' => 'nullable|string',
'city' => 'nullable|string',
'area' => 'nullable|string',
'live_in_out' => 'nullable|string',
'category' => 'required|string', 'category' => 'required|string',
'experience' => 'required|string', 'experience' => 'required|string',
'salary' => 'nullable|numeric',
'bio' => 'nullable|string' 'bio' => 'nullable|string'
]); ]);
$worker = \App\Models\Worker::findOrFail($id);
$category = \App\Models\WorkerCategory::where('name', $validated['category'])->first();
if ($category) {
$worker->category_id = $category->id;
}
$worker->name = $validated['name'];
$worker->phone = $validated['phone'];
$worker->gender = $validated['gender'] ?? $worker->gender;
$worker->language = $validated['language'] ?? $worker->language;
$worker->country = $validated['country'] ?? $worker->country;
$worker->city = $validated['city'] ?? $worker->city;
$worker->area = $validated['area'] ?? $worker->area;
$worker->live_in_out = $validated['live_in_out'] ?? $worker->live_in_out;
$worker->experience = $validated['experience'];
$worker->salary = $validated['salary'] ?? $worker->salary;
$worker->bio = $validated['bio'] ?? $worker->bio;
$worker->save();
return back()->with('success', "Worker #{$id} profile details moderated and updated successfully."); return back()->with('success', "Worker #{$id} profile details moderated and updated successfully.");
} }
@ -160,10 +131,6 @@ public function updateProfile(Request $request, $id)
*/ */
public function verifyEmployer(Request $request, $id) public function verifyEmployer(Request $request, $id)
{ {
$sponsor = \App\Models\Sponsor::findOrFail($id);
$sponsor->is_verified = true;
$sponsor->save();
return back()->with('success', "Employer #{$id} verification status set to APPROVED."); return back()->with('success', "Employer #{$id} verification status set to APPROVED.");
} }
@ -176,10 +143,6 @@ public function suspendEmployer(Request $request, $id)
'reason' => 'nullable|string' 'reason' => 'nullable|string'
]); ]);
$sponsor = \App\Models\Sponsor::findOrFail($id);
$sponsor->status = 'suspended';
$sponsor->save();
return back()->with('success', "Employer #{$id} has been suspended. Reason: " . ($validated['reason'] ?? 'Unspecified violation')); return back()->with('success', "Employer #{$id} has been suspended. Reason: " . ($validated['reason'] ?? 'Unspecified violation'));
} }
} }

View File

@ -16,69 +16,148 @@ public function index(Request $request)
{ {
$status = $request->input('status', 'all'); $status = $request->input('status', 'all');
$query = \App\Models\Worker::with('documents'); // Scaffolding mock dataset supporting pagination and status filtering
$allVerifications = [
[
'id' => 101,
'worker_name' => 'Fatima Zahra',
'nationality' => 'Morocco',
'passport_number' => 'MA9823471',
'processed_at' => '2026-05-14 10:15',
'status' => 'approved',
'verification_method' => 'Auto-OCR',
'document_images' => [
'https://images.unsplash.com/photo-1544717305-2782549b5136?q=80&w=600&auto=format&fit=crop',
'https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?q=80&w=600&auto=format&fit=crop'
],
'ocr_data' => [
'Name' => 'Fatima Zahra',
'DOB' => '1992-08-14',
'Nationality' => 'Morocco',
'Passport No.' => 'MA9823471',
'Expiry' => '2030-11-20',
]
],
[
'id' => 102,
'worker_name' => 'Maria Santos',
'nationality' => 'Philippines',
'passport_number' => 'P1234567A',
'processed_at' => '2026-05-14 09:30',
'status' => 'approved',
'verification_method' => 'Auto-OCR',
'document_images' => [
'https://images.unsplash.com/photo-1580489944761-15a19d654956?q=80&w=600&auto=format&fit=crop'
],
'ocr_data' => [
'Name' => 'Maria Santos',
'DOB' => '1990-05-10',
'Nationality' => 'Philippines',
'Passport No.' => 'P1234567A',
'Expiry' => '2029-04-15',
]
],
[
'id' => 103,
'worker_name' => 'Amina Diop',
'nationality' => 'Senegal',
'passport_number' => 'SN8765432',
'processed_at' => '2026-05-13 14:20',
'status' => 'approved',
'verification_method' => 'Auto-OCR',
'document_images' => [
'https://images.unsplash.com/photo-1531746020798-e6953c6e8e04?q=80&w=600&auto=format&fit=crop'
],
'ocr_data' => [
'Name' => 'Amina Diop',
'DOB' => '1994-12-01',
'Nationality' => 'Senegal',
'Passport No.' => 'SN8765432',
'Expiry' => '2031-08-10',
]
],
[
'id' => 104,
'worker_name' => 'Siti Aminah',
'nationality' => 'Indonesia',
'passport_number' => 'B76543210',
'processed_at' => '2026-05-13 11:05',
'status' => 'approved',
'verification_method' => 'Auto-OCR',
'document_images' => [
'https://images.unsplash.com/photo-1534528741775-53994a69daeb?q=80&w=600&auto=format&fit=crop'
],
'ocr_data' => [
'Name' => 'Siti Aminah',
'DOB' => '1988-03-25',
'Nationality' => 'Indonesia',
'Passport No.' => 'B76543210',
'Expiry' => '2028-09-30',
]
],
[
'id' => 105,
'worker_name' => 'Grace Omondi',
'nationality' => 'Kenya',
'passport_number' => 'KE5432198',
'processed_at' => '2026-05-12 16:45',
'status' => 'approved',
'verification_method' => 'Auto-OCR',
'document_images' => [
'https://images.unsplash.com/photo-1567532939604-b6b5b0db2604?q=80&w=600&auto=format&fit=crop'
],
'ocr_data' => [
'Name' => 'Grace Omondi',
'DOB' => '1995-07-19',
'Nationality' => 'Kenya',
'Passport No.' => 'KE5432198',
'Expiry' => '2025-02-14',
]
],
[
'id' => 106,
'worker_name' => 'Leila Bekri',
'nationality' => 'Tunisia',
'passport_number' => 'TN4321987',
'processed_at' => '2026-05-12 10:10',
'status' => 'approved',
'verification_method' => 'Auto-OCR',
'document_images' => [
'https://images.unsplash.com/photo-1544005313-94ddf0286df2?q=80&w=600&auto=format&fit=crop'
],
'ocr_data' => [
'Name' => 'Leila Bekri',
'DOB' => '1991-09-08',
'Nationality' => 'Tunisia',
'Passport No.' => 'TN4321987',
'Expiry' => '2032-01-25',
]
],
];
if ($status === 'approved') { // Filter by status
$query->where('verified', true); if ($status !== 'all') {
} elseif ($status === 'pending') { $filtered = array_filter($allVerifications, function ($item) use ($status) {
$query->where('verified', false); return $item['status'] === $status;
});
} else {
$filtered = $allVerifications;
} }
$paginator = $query->paginate(20); $page = $request->input('page', 1);
$perPage = 20;
$total = count($filtered);
$slice = array_slice($filtered, ($page - 1) * $perPage, $perPage);
$paginator->getCollection()->transform(function($worker) { // Generate paginator
$passportDoc = $worker->documents->where('type', 'passport')->first(); $paginator = new LengthAwarePaginator($slice, $total, $perPage, $page, [
$visaDoc = $worker->documents->where('type', 'visa')->first(); 'path' => $request->url(),
$doc = $passportDoc ?: ($visaDoc ?: $worker->documents->first()); 'query' => $request->query(),
]);
return [
'id' => $worker->id,
'worker_name' => $worker->name,
'nationality' => $worker->nationality,
'passport_number' => $passportDoc ? $passportDoc->number : ($doc ? $doc->number : 'N/A'),
'processed_at' => $worker->updated_at ? $worker->updated_at->format('Y-m-d H:i') : 'N/A',
'status' => $worker->verified ? 'approved' : 'flagged',
'confidence' => $doc && $doc->ocr_accuracy ? intval($doc->ocr_accuracy) : 95,
'verification_method' => $doc && $doc->ocr_accuracy ? 'Auto-OCR' : 'Manual',
'document_type' => $doc ? ucfirst($doc->type) : 'Passport',
'warnings' => $worker->verified ? [] : ($doc ? [] : ['No documents uploaded']),
'document_images' => $worker->documents->map(function($d) {
return $d->file_path ?: 'https://images.unsplash.com/photo-1544717305-2782549b5136?q=80&w=600&auto=format&fit=crop';
})->toArray(),
'ocr_data' => [
'Name' => $worker->name,
'DOB' => $worker->age ? date('Y-m-d', strtotime('-' . $worker->age . ' years')) : 'N/A',
'Nationality' => $worker->nationality,
'Passport No.' => $passportDoc ? $passportDoc->number : 'N/A',
'Expiry' => $passportDoc && $passportDoc->expiry_date ? (is_string($passportDoc->expiry_date) ? date('Y-m-d', strtotime($passportDoc->expiry_date)) : $passportDoc->expiry_date->format('Y-m-d')) : 'N/A',
]
];
});
$history = \Illuminate\Support\Facades\DB::table('audit_logs')
->where('category', 'verification')
->orWhere('action', 'like', '%verification%')
->orWhere('action', 'like', '%verify%')
->orderBy('created_at', 'desc')
->take(5)
->get()
->map(function ($log) {
return [
'time' => date('Y-m-d H:i', strtotime($log->created_at)),
'text' => $log->action,
'ip' => $log->ip_address ?: 'Auto-System',
];
})->toArray();
return Inertia::render('Admin/Workers/Verifications', [ return Inertia::render('Admin/Workers/Verifications', [
'verifications' => $paginator, 'verifications' => $paginator,
'status' => $status, 'status' => $status,
'summary' => [
'auto_approve_rate' => \App\Models\Worker::count() > 0 ? round((\App\Models\Worker::where('verified', true)->count() / \App\Models\Worker::count()) * 100, 1) : 0.0,
'average_match_score' => round(\App\Models\WorkerDocument::avg('ocr_accuracy') ?: 95.0, 1),
'flagged_count' => \App\Models\Worker::where('verified', false)->count(),
],
'history' => $history,
]); ]);
} }
@ -93,47 +172,7 @@ public function verify(Request $request, $worker)
'ocr_overrides' => 'nullable|array', 'ocr_overrides' => 'nullable|array',
]); ]);
$workerModel = \App\Models\Worker::findOrFail($worker); $statusMsg = $validated['action'] === 'approve' ? 'approved' : 'rejected';
if ($validated['action'] === 'approve') {
$workerModel->verified = true;
if (!empty($validated['ocr_overrides'])) {
$overrides = $validated['ocr_overrides'];
if (isset($overrides['Name'])) {
$workerModel->name = $overrides['Name'];
}
if (isset($overrides['Nationality'])) {
$workerModel->nationality = $overrides['Nationality'];
}
if (isset($overrides['Passport No.'])) {
$passportDoc = $workerModel->documents()->where('type', 'passport')->first();
if ($passportDoc) {
$passportDoc->number = $overrides['Passport No.'];
if (isset($overrides['Expiry'])) {
$passportDoc->expiry_date = $overrides['Expiry'];
}
$passportDoc->save();
}
}
}
$workerModel->save();
$statusMsg = 'approved';
} else {
$workerModel->verified = false;
$workerModel->save();
$statusMsg = 'rejected';
}
// Insert into audit logs
\Illuminate\Support\Facades\DB::table('audit_logs')->insert([
'category' => 'verification',
'user' => auth()->user() ? auth()->user()->email : 'Admin',
'action' => "Admin manually " . ($validated['action'] === 'approve' ? 'approved' : 'rejected') . " verification for worker #{$workerModel->id} ({$workerModel->name})",
'ip_address' => $request->ip(),
'created_at' => now(),
'updated_at' => now(),
]);
return back()->with('success', "Worker verification #{$worker} has been {$statusMsg} successfully."); return back()->with('success', "Worker verification #{$worker} has been {$statusMsg} successfully.");
} }

View File

@ -5,7 +5,6 @@
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\User; use App\Models\User;
use App\Models\EmployerProfile; use App\Models\EmployerProfile;
use App\Models\Sponsor;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
@ -22,17 +21,10 @@ class EmployerAuthController extends Controller
public function login(Request $request) public function login(Request $request)
{ {
$validator = Validator::make($request->all(), [ $validator = Validator::make($request->all(), [
'email' => 'nullable|string', 'email' => 'required|email',
'mobile' => 'nullable|string',
'password' => 'required|string', 'password' => 'required|string',
]); ]);
$validator->after(function ($validator) use ($request) {
if (!$request->filled('email') && !$request->filled('mobile')) {
$validator->errors()->add('mobile', 'Either mobile number or email is required.');
}
});
if ($validator->fails()) { if ($validator->fails()) {
return response()->json([ return response()->json([
'success' => false, 'success' => false,
@ -42,49 +34,15 @@ public function login(Request $request)
} }
try { try {
$loginValue = $request->input('mobile') ?? $request->input('email'); $user = User::where('email', $request->email)->where('role', 'employer')->first();
$sponsor = Sponsor::where('mobile', $loginValue) if (!$user || !Hash::check($request->password, $user->password)) {
->orWhere('email', $loginValue)
->first();
if ($sponsor) {
$user = User::where('email', $sponsor->email)
->where('role', 'employer')
->first();
} else {
$user = User::where('email', $loginValue)
->where('role', 'employer')
->first();
}
if (!$sponsor && !$user) {
return response()->json([ return response()->json([
'success' => false, 'success' => false,
'message' => 'Invalid credentials.' 'message' => 'Invalid email or password.'
], 401); ], 401);
} }
$passwordHash = $sponsor ? $sponsor->password : $user->password;
if (!Hash::check($request->password, $passwordHash)) {
return response()->json([
'success' => false,
'message' => 'Invalid credentials.'
], 401);
}
if ($sponsor && $sponsor->status === 'suspended') {
return response()->json([
'success' => false,
'message' => 'Your account has been suspended. Please contact support.'
], 403);
}
$apiToken = Str::random(80);
if ($user) {
// Employer account
$profile = EmployerProfile::where('user_id', $user->id)->first(); $profile = EmployerProfile::where('user_id', $user->id)->first();
$verification_status = $profile ? $profile->verification_status : 'approved'; $verification_status = $profile ? $profile->verification_status : 'approved';
@ -103,43 +61,21 @@ public function login(Request $request)
], 403); ], 403);
} }
// Generate and assign a fresh API token
$apiToken = Str::random(80);
$user->update(['api_token' => $apiToken]); $user->update(['api_token' => $apiToken]);
if ($sponsor) {
$sponsor->update([
'api_token' => $apiToken,
'last_login_at' => now(),
]);
}
return response()->json([ return response()->json([
'success' => true, 'success' => true,
'message' => 'Login successful.', 'message' => 'Employer logged in successfully.',
'role' => 'employer',
'data' => [ 'data' => [
'employer' => $user->load('employerProfile'), 'employer' => $user->load('employerProfile'),
'token' => $apiToken 'token' => $apiToken
] ]
], 200); ], 200);
} else {
// Pure Sponsor account
$sponsor->update([
'api_token' => $apiToken,
'last_login_at' => now(),
]);
return response()->json([
'success' => true,
'message' => 'Login successful.',
'role' => 'sponsor',
'data' => [
'sponsor' => $sponsor->makeHidden(['password', 'api_token']),
'token' => $apiToken
]
], 200);
}
} catch (\Exception $e) { } catch (\Exception $e) {
logger()->error('Mobile Login Failure: ' . $e->getMessage()); logger()->error('Mobile Employer Login Failure: ' . $e->getMessage());
return response()->json([ return response()->json([
'success' => false, 'success' => false,
@ -474,7 +410,7 @@ public function plans()
'price' => 99.00, 'price' => 99.00,
'currency' => 'AED', 'currency' => 'AED',
'period' => 'month', 'period' => 'month',
'features' => ['Browse 500+ verified workers', 'Shortlist up to 10 candidates', 'Standard OCR verification'], 'features' => ['Browse 500+ verified workers', 'Shortlist up to 10 candidates', 'Standard OCR vetting'],
'popular' => false, 'popular' => false,
], ],
[ [

View File

@ -22,7 +22,7 @@ public function getPayments(Request $request)
try { try {
$employerId = $employer->id; $employerId = $employer->id;
// Auto-seed payments if none exist for a richer UX // Auto-seed a couple of payments if none exist for a richer UX
$paymentsCount = Payment::where('user_id', $employerId)->count(); $paymentsCount = Payment::where('user_id', $employerId)->count();
if ($paymentsCount === 0) { if ($paymentsCount === 0) {
// Determine their plan // Determine their plan
@ -30,28 +30,31 @@ public function getPayments(Request $request)
$planName = $subscription ? (ucfirst($subscription->plan_id) . ' Pass Subscription') : 'Premium Sponsor Pass Subscription'; $planName = $subscription ? (ucfirst($subscription->plan_id) . ' Pass Subscription') : 'Premium Sponsor Pass Subscription';
$planAmount = $subscription ? $subscription->amount_aed : 199.00; $planAmount = $subscription ? $subscription->amount_aed : 199.00;
for ($i = 0; $i < 8; $i++) {
Payment::create([ Payment::create([
'user_id' => $employerId, 'user_id' => $employerId,
'amount' => $planAmount, 'amount' => $planAmount,
'currency' => 'AED', 'currency' => 'AED',
'description' => $planName, 'description' => $planName,
'status' => 'success', 'status' => 'success',
'created_at' => now()->subMonths($i)->subDays(rand(1, 5)), 'created_at' => now()->subDays(15),
'updated_at' => now()->subMonths($i)->subDays(rand(1, 5)), 'updated_at' => now()->subDays(15),
]);
Payment::create([
'user_id' => $employerId,
'amount' => 49.00,
'currency' => 'AED',
'description' => 'OCR Document Vetting Fee',
'status' => 'success',
'created_at' => now()->subDays(5),
'updated_at' => now()->subDays(5),
]); ]);
}
} }
$page = (int)$request->input('page', 1); $page = (int)$request->input('page', 1);
$perPage = (int)$request->input('per_page', 15); $perPage = (int)$request->input('per_page', 15);
$query = Payment::where('user_id', $employerId) $query = Payment::where('user_id', $employerId)->latest();
->where(function ($q) {
$q->where('description', 'like', '%Subscription%')
->orWhere('description', 'like', '%Pass%');
})
->latest();
$total = $query->count(); $total = $query->count();
$offset = ($page - 1) * $perPage; $offset = ($page - 1) * $perPage;

View File

@ -42,8 +42,6 @@ public function getProfile(Request $request)
'language' => $profile->language ?? 'English', 'language' => $profile->language ?? 'English',
'notifications' => (bool)($profile->notifications ?? true), 'notifications' => (bool)($profile->notifications ?? true),
'verification_status' => $profile->verification_status ?? 'approved', 'verification_status' => $profile->verification_status ?? 'approved',
'emirates_id_number' => $profile->emirates_id_number,
'emirates_id_expiry' => $profile->emirates_id_expiry,
]; ];
return response()->json([ return response()->json([
@ -162,8 +160,6 @@ public function updateProfile(Request $request)
'language' => $profile->language, 'language' => $profile->language,
'notifications' => (bool)$profile->notifications, 'notifications' => (bool)$profile->notifications,
'verification_status' => $profile->verification_status ?? 'approved', 'verification_status' => $profile->verification_status ?? 'approved',
'emirates_id_number' => $profile->emirates_id_number,
'emirates_id_expiry' => $profile->emirates_id_expiry,
]; ];
return response()->json([ return response()->json([

View File

@ -30,8 +30,11 @@ private function formatWorker(Worker $w)
$jobTypes = ['full-time', 'part-time', 'live-in', 'live-out']; $jobTypes = ['full-time', 'part-time', 'live-in', 'live-out'];
$preferredJobType = $jobTypes[$w->id % 4]; $preferredJobType = $jobTypes[$w->id % 4];
// Emirates ID verification status (dynamic passport status) // Availability status: Active / Hidden / Hired
$emiratesIdStatus = $w->emirates_id_status; $availabilityStatus = ucfirst($w->status === 'active' ? 'Active' : ($w->status === 'hidden' ? 'Hidden' : ($w->status === 'Hired' ? 'Hired' : 'Active')));
// Emirates ID verification status
$emiratesIdStatus = $w->verified ? 'Emirates ID Verified' : 'EID Vetting Pending';
// Exact Skills mapping: cooking, driving, childcare, cleaning, elderly care, gardening // Exact Skills mapping: cooking, driving, childcare, cleaning, elderly care, gardening
$skillsList = ['cooking', 'driving', 'childcare', 'cleaning', 'elderly care', 'gardening']; $skillsList = ['cooking', 'driving', 'childcare', 'cleaning', 'elderly care', 'gardening'];
@ -41,7 +44,8 @@ private function formatWorker(Worker $w)
]; ];
// Visa status // Visa status
$visaStatus = $w->visa_status; $visaStatusesList = ['Residence Visa', 'Tourist Visa', 'Employment Visa', 'Cancelled Visa', 'Own Visa'];
$visaStatus = $visaStatusesList[$w->id % 5];
// Optional profile photos // Optional profile photos
$photos = [ $photos = [
@ -61,8 +65,8 @@ private function formatWorker(Worker $w)
'nationality' => $w->nationality, 'nationality' => $w->nationality,
'photo' => $photo, 'photo' => $photo,
'emirates_id_status' => $emiratesIdStatus, 'emirates_id_status' => $emiratesIdStatus,
'passport_status' => $w->passport_status,
'skills' => $mappedSkills, 'skills' => $mappedSkills,
'availability_status' => $availabilityStatus,
'visa_status' => $visaStatus, 'visa_status' => $visaStatus,
'experience' => $w->experience, 'experience' => $w->experience,
'religion' => $w->religion, 'religion' => $w->religion,
@ -83,7 +87,7 @@ private function formatWorker(Worker $w)
public function getWorkers(Request $request) public function getWorkers(Request $request)
{ {
try { try {
$query = Worker::with(['category', 'skills', 'documents']) $query = Worker::with(['category', 'skills'])
->where('status', '!=', 'Hired') ->where('status', '!=', 'Hired')
->where('status', '!=', 'hidden'); ->where('status', '!=', 'hidden');
@ -100,16 +104,12 @@ public function getWorkers(Request $request)
$dbWorkers = $query->latest()->get(); $dbWorkers = $query->latest()->get();
$formattedWorkers = $dbWorkers->map(function ($w) { $formattedWorkers = $dbWorkers->map(function ($w) {
$isPending = str_contains(strtolower($w->passport_status), 'pending');
if ($isPending || $w->status === 'hidden' || $w->status === 'Hired') {
return null;
}
return $this->formatWorker($w); return $this->formatWorker($w);
})->filter()->values(); })->filter()->values();
$workersArray = $formattedWorkers->toArray(); $workersArray = $formattedWorkers->toArray();
// Apply filters: skills, language/languages, nationality // Apply filters: skills, language/languages, nationality, availability
if ($request->filled('nationality')) { if ($request->filled('nationality')) {
$nationality = strtolower($request->nationality); $nationality = strtolower($request->nationality);
$workersArray = array_values(array_filter($workersArray, function ($c) use ($nationality) { $workersArray = array_values(array_filter($workersArray, function ($c) use ($nationality) {
@ -117,6 +117,14 @@ public function getWorkers(Request $request)
})); }));
} }
if ($request->filled('availability')) {
$availability = strtolower($request->availability);
$workersArray = array_values(array_filter($workersArray, function ($c) use ($availability) {
$val = $c['availability_status'] ?? ($c['availability'] ?? '');
return strtolower($val) === $availability;
}));
}
if ($request->filled('skills')) { if ($request->filled('skills')) {
$skills = $request->skills; $skills = $request->skills;
$skillsArray = is_array($skills) ? $skills : array_filter(array_map('trim', explode(',', $skills))); $skillsArray = is_array($skills) ? $skills : array_filter(array_map('trim', explode(',', $skills)));
@ -482,6 +490,8 @@ public function getWorkerDetail(Request $request, $id)
{ {
/** @var User $employer */ /** @var User $employer */
$employer = $request->attributes->get('employer'); $employer = $request->attributes->get('employer');
try {
$w = Worker::with(['category', 'skills', 'documents'])->find($id); $w = Worker::with(['category', 'skills', 'documents'])->find($id);
if (!$w) { if (!$w) {
@ -491,15 +501,6 @@ public function getWorkerDetail(Request $request, $id)
], 404); ], 404);
} }
try {
$isPending = str_contains(strtolower($w->passport_status), 'pending');
if ($w->status === 'hidden' || $isPending) {
return response()->json([
'success' => false,
'message' => 'Worker profile not found.'
], 404);
}
// Record employer profile view (Requirement 3) // Record employer profile view (Requirement 3)
if ($employer) { if ($employer) {
ProfileView::updateOrCreate( ProfileView::updateOrCreate(
@ -515,8 +516,11 @@ public function getWorkerDetail(Request $request, $id)
$jobTypes = ['full-time', 'part-time', 'live-in', 'live-out']; $jobTypes = ['full-time', 'part-time', 'live-in', 'live-out'];
$preferredJobType = $jobTypes[$w->id % 4]; $preferredJobType = $jobTypes[$w->id % 4];
// Availability status
$availabilityStatus = ucfirst($w->status === 'active' ? 'Active' : ($w->status === 'hidden' ? 'Hidden' : ($w->status === 'Hired' ? 'Hired' : 'Active')));
// Emirates ID status // Emirates ID status
$emiratesIdStatus = $w->verified ? 'Emirates ID Verified' : 'EID Verification Pending'; $emiratesIdStatus = $w->verified ? 'Emirates ID Verified' : 'EID Vetting Pending';
// Skills mapping // Skills mapping
$skillsList = ['cooking', 'driving', 'childcare', 'cleaning', 'elderly care', 'gardening']; $skillsList = ['cooking', 'driving', 'childcare', 'cleaning', 'elderly care', 'gardening'];
@ -583,10 +587,7 @@ public function getWorkerDetail(Request $request, $id)
->get(); ->get();
$similarWorkers = $simDb->map(function($sw) { $similarWorkers = $simDb->map(function($sw) {
$isPending = str_contains(strtolower($sw->passport_status), 'pending'); $availabilityStatus = ucfirst($sw->status === 'active' ? 'Active' : ($sw->status === 'hidden' ? 'Hidden' : ($sw->status === 'Hired' ? 'Hired' : 'Active')));
if ($isPending || $sw->status === 'hidden' || $sw->status === 'Hired') {
return null;
}
return [ return [
'id' => $sw->id, 'id' => $sw->id,
@ -594,8 +595,9 @@ public function getWorkerDetail(Request $request, $id)
'nationality' => $sw->nationality, 'nationality' => $sw->nationality,
'rating' => 4.7, 'rating' => 4.7,
'verified' => (bool)$sw->verified, 'verified' => (bool)$sw->verified,
'availability_status' => $availabilityStatus,
]; ];
})->filter()->values()->toArray(); })->toArray();
// Check if there is an existing conversation between this employer and this worker // Check if there is an existing conversation between this employer and this worker
$conversation = \App\Models\Conversation::where('employer_id', $employer->id) $conversation = \App\Models\Conversation::where('employer_id', $employer->id)
@ -609,6 +611,7 @@ public function getWorkerDetail(Request $request, $id)
'photo' => $photo, 'photo' => $photo,
'emirates_id_status' => $emiratesIdStatus, 'emirates_id_status' => $emiratesIdStatus,
'skills' => $mappedSkills, 'skills' => $mappedSkills,
'availability_status' => $availabilityStatus,
'visa_status' => $visaStatus, 'visa_status' => $visaStatus,
'experience' => $w->experience, 'experience' => $w->experience,
'experience_years' => 5, 'experience_years' => 5,

View File

@ -1,320 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
use App\Models\User;
use App\Models\Worker;
use App\Models\Conversation;
use App\Models\Review;
class ReportController extends Controller
{
/**
* Submit a report from a worker.
* POST /api/workers/report
*/
public function reportFromWorker(Request $request)
{
/** @var Worker $worker */
$worker = $request->attributes->get('worker');
$validator = Validator::make($request->all(), [
'type' => 'required|in:Chat,Review',
'item_id' => 'required',
'user_id' => 'nullable',
'reason' => 'required|string|max:255',
'others' => 'nullable|string|max:255',
'description' => 'nullable|string|max:2000',
]);
if ($validator->fails()) {
return response()->json([
'success' => false,
'message' => 'Validation error.',
'errors' => $validator->errors()
], 422);
}
try {
$reportedName = '';
$reportedRole = 'Sponsor';
$reportedAvatar = null;
if ($request->type === 'Chat') {
$conversation = Conversation::where('id', $request->item_id)
->where('worker_id', $worker->id)
->first();
if (!$conversation) {
return response()->json([
'success' => false,
'message' => 'Conversation not found or unauthorized.'
], 404);
}
$employer = $conversation->employer;
if (!$employer) {
return response()->json([
'success' => false,
'message' => 'Employer associated with the conversation not found.'
], 404);
}
$reportedName = $employer->name;
} else {
// Review
$review = Review::where('id', $request->item_id)
->where('worker_id', $worker->id)
->first();
if (!$review) {
return response()->json([
'success' => false,
'message' => 'Review not found or unauthorized.'
], 404);
}
$employer = User::find($review->employer_id);
if (!$employer) {
return response()->json([
'success' => false,
'message' => 'Employer associated with the review not found.'
], 404);
}
$reportedName = $employer->name;
}
$reportId = 'REP-' . Str::upper(Str::random(8));
$actualReason = $request->reason;
if (strtolower($request->reason) === 'others' && $request->filled('others')) {
$actualReason = 'Others: ' . $request->others;
}
// Insert to moderation_reports
DB::table('moderation_reports')->insert([
'id' => $reportId,
'type' => $request->type,
'reported_user_id' => $request->user_id,
'reported_user_name' => $reportedName,
'reported_user_role' => $reportedRole,
'reported_user_avatar' => $reportedAvatar,
'reported_by_name' => $worker->name,
'reported_by_role' => 'Worker',
'reported_by_avatar' => null,
'reason' => $actualReason,
'priority' => 'Medium',
'status' => 'Pending',
'description' => $request->description,
'reported_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]);
// Add Audit Log
DB::table('audit_logs')->insert([
'category' => 'user_activity',
'user' => $worker->name . ' (Worker)',
'action' => 'Submitted Safety Report ' . $reportId . ' on ' . $reportedName . ' (Type: ' . $request->type . ', Reason: ' . $request->reason . ')',
'ip_address' => $request->ip() ?: '127.0.0.1',
'created_at' => now(),
'updated_at' => now()
]);
return response()->json([
'success' => true,
'message' => 'Report submitted successfully. Our safety team will review it shortly.',
'report_id' => $reportId
], 201);
} catch (\Exception $e) {
logger()->error('Worker API Report Submission Failure: ' . $e->getMessage());
return response()->json([
'success' => false,
'message' => 'An error occurred while submitting the report.',
'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error'
], 500);
}
}
/**
* Submit a report from an employer.
* POST /api/employers/report
*/
public function reportFromEmployer(Request $request)
{
/** @var User $employer */
$employer = $request->attributes->get('employer');
$validator = Validator::make($request->all(), [
'type' => 'required|in:Chat,Review',
'item_id' => 'required',
'user_id' => 'nullable',
'reason' => 'required|string|max:255',
'others' => 'nullable|string|max:255',
'description' => 'nullable|string|max:2000',
]);
if ($validator->fails()) {
return response()->json([
'success' => false,
'message' => 'Validation error.',
'errors' => $validator->errors()
], 422);
}
try {
$reportedName = '';
$reportedRole = 'Worker';
$reportedAvatar = null;
if ($request->type === 'Chat') {
$conversation = Conversation::where('id', $request->item_id)
->where('employer_id', $employer->id)
->first();
if (!$conversation) {
return response()->json([
'success' => false,
'message' => 'Conversation not found or unauthorized.'
], 404);
}
$worker = $conversation->worker;
if (!$worker) {
return response()->json([
'success' => false,
'message' => 'Worker associated with the conversation not found.'
], 404);
}
$reportedName = $worker->name;
} else {
// Review
$review = Review::where('id', $request->item_id)
->where('employer_id', $employer->id)
->first();
if (!$review) {
return response()->json([
'success' => false,
'message' => 'Review not found or unauthorized.'
], 404);
}
$worker = Worker::find($review->worker_id);
if (!$worker) {
return response()->json([
'success' => false,
'message' => 'Worker associated with the review not found.'
], 404);
}
$reportedName = $worker->name;
}
$reportId = 'REP-' . Str::upper(Str::random(8));
$actualReason = $request->reason;
if (strtolower($request->reason) === 'others' && $request->filled('others')) {
$actualReason = 'Others: ' . $request->others;
}
// Insert to moderation_reports
DB::table('moderation_reports')->insert([
'id' => $reportId,
'type' => $request->type,
'reported_user_id' => $request->user_id,
'reported_user_name' => $reportedName,
'reported_user_role' => $reportedRole,
'reported_user_avatar' => $reportedAvatar,
'reported_by_name' => $employer->name,
'reported_by_role' => 'Sponsor',
'reported_by_avatar' => null,
'reason' => $actualReason,
'priority' => 'Medium',
'status' => 'Pending',
'description' => $request->description,
'reported_at' => now(),
'created_at' => now(),
'updated_at' => now(),
]);
// Add Audit Log
DB::table('audit_logs')->insert([
'category' => 'user_activity',
'user' => $employer->name . ' (Sponsor)',
'action' => 'Submitted Safety Report ' . $reportId . ' on ' . $reportedName . ' (Type: ' . $request->type . ', Reason: ' . $request->reason . ')',
'ip_address' => $request->ip() ?: '127.0.0.1',
'created_at' => now(),
'updated_at' => now()
]);
return response()->json([
'success' => true,
'message' => 'Report submitted successfully. Our safety team will review it shortly.',
'report_id' => $reportId
], 201);
} catch (\Exception $e) {
logger()->error('Employer API Report Submission Failure: ' . $e->getMessage());
return response()->json([
'success' => false,
'message' => 'An error occurred while submitting the report.',
'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error'
], 500);
}
}
/**
* Get active report reasons for workers.
* GET /api/workers/report-reasons
*/
public function getReasonsForWorker(Request $request)
{
$type = $request->query('type'); // Chat or Review
$query = DB::table('report_reasons')->where('status', 'Active');
if ($type && in_array($type, ['Chat', 'Review'])) {
$query->whereIn('type', [$type, 'Both']);
}
$reasons = $query->orderBy('id', 'asc')->get(['id', 'reason', 'type']);
return response()->json([
'success' => true,
'reasons' => $reasons
], 200);
}
/**
* Get active report reasons for employers.
* GET /api/employers/report-reasons
*/
public function getReasonsForEmployer(Request $request)
{
$type = $request->query('type'); // Chat or Review
$query = DB::table('report_reasons')->where('status', 'Active');
if ($type && in_array($type, ['Chat', 'Review'])) {
$query->whereIn('type', [$type, 'Both']);
}
$reasons = $query->orderBy('id', 'asc')->get(['id', 'reason', 'type']);
return response()->json([
'success' => true,
'reasons' => $reasons
], 200);
}
}

View File

@ -1,162 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Sponsor;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Validator;
use Illuminate\Support\Str;
class SponsorAuthController extends Controller
{
/**
* Register a new Sponsor account.
*
* Required fields: full_name, mobile, password, license_file
* Optional: organization_name, email, nationality, city, address, country_code
*
* POST /api/sponsors/register
*/
public function register(Request $request)
{
$validator = Validator::make($request->all(), [
'full_name' => 'required|string|max:255',
'mobile' => 'required|string|max:50|unique:sponsors,mobile',
'password' => 'required|string|min:6',
'license_file' => 'required|file|mimes:jpeg,png,jpg,pdf|max:10240',
'organization_name' => 'nullable|string|max:255',
'email' => 'nullable|email|max:255|unique:sponsors,email',
'nationality' => 'nullable|string|max:100',
'city' => 'nullable|string|max:100',
'address' => 'nullable|string|max:255',
'country_code' => 'nullable|string|max:10',
], [
'mobile.unique' => 'This mobile number is already registered.',
'email.unique' => 'This email address is already registered.',
'license_file.required' => 'Please upload your organization or trade license.',
]);
if ($validator->fails()) {
return response()->json([
'success' => false,
'message' => 'Validation error.',
'errors' => $validator->errors()
], 422);
}
try {
// Auto-generate email if not provided
$mobileClean = preg_replace('/[^0-9]/', '', $request->mobile);
$email = $request->email ?? "sponsor.{$mobileClean}@migrant.ae";
if (!$request->email && Sponsor::where('email', $email)->exists()) {
$email = "sponsor.{$mobileClean}." . rand(10, 99) . "@migrant.ae";
}
// Store license file
$destinationPath = public_path('uploads/licenses');
if (!file_exists($destinationPath)) {
mkdir($destinationPath, 0755, true);
}
$licenseFile = $request->file('license_file');
$licenseFileName = time() . '_license_' . preg_replace('/[^a-zA-Z0-9_.-]/', '', $licenseFile->getClientOriginalName());
$licenseFile->move($destinationPath, $licenseFileName);
$licensePath = 'uploads/licenses/' . $licenseFileName;
$apiToken = Str::random(80);
$sponsor = DB::transaction(function () use ($request, $email, $licensePath, $apiToken) {
return Sponsor::create([
'full_name' => $request->full_name,
'organization_name' => $request->organization_name,
'email' => $email,
'mobile' => $request->mobile,
'password' => Hash::make($request->password),
'country_code' => $request->country_code,
'nationality' => $request->nationality,
'city' => $request->city,
'address' => $request->address,
'license_file' => $licensePath,
'status' => 'active',
'is_verified' => false,
'subscription_status' => 'none',
'api_token' => $apiToken,
]);
});
return response()->json([
'success' => true,
'message' => 'Sponsor account registered successfully. Your license is pending admin review.',
'data' => [
'sponsor' => $sponsor->makeHidden(['password', 'api_token']),
'token' => $apiToken,
]
], 201);
} catch (\Exception $e) {
logger()->error('Sponsor Registration Failure: ' . $e->getMessage());
return response()->json([
'success' => false,
'message' => 'An error occurred during registration. Please try again.',
'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error'
], 500);
}
}
/**
* Authenticate a Sponsor and return their bearer token.
*
* POST /api/sponsors/login
*/
public function login(Request $request)
{
$validator = Validator::make($request->all(), [
'mobile' => 'required|string',
'password' => 'required|string',
]);
if ($validator->fails()) {
return response()->json([
'success' => false,
'message' => 'Validation error.',
'errors' => $validator->errors()
], 422);
}
$sponsor = Sponsor::where('mobile', $request->mobile)->first();
if (!$sponsor || !Hash::check($request->password, $sponsor->password)) {
return response()->json([
'success' => false,
'message' => 'Invalid mobile number or password.'
], 401);
}
if ($sponsor->status === 'suspended') {
return response()->json([
'success' => false,
'message' => 'Your account has been suspended. Please contact support.'
], 403);
}
// Rotate token on each login for security
$apiToken = Str::random(80);
$sponsor->update([
'api_token' => $apiToken,
'last_login_at' => now(),
]);
return response()->json([
'success' => true,
'message' => 'Login successful.',
'data' => [
'sponsor' => $sponsor->makeHidden(['password', 'api_token']),
'token' => $apiToken,
]
], 200);
}
}

View File

@ -1,160 +0,0 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\Announcement;
use App\Models\Sponsor;
use Illuminate\Http\Request;
class SponsorController extends Controller
{
/**
* GET /api/sponsors/dashboard
*
* Returns the sponsor's profile summary and unread charity event count.
*/
public function getDashboard(Request $request)
{
/** @var Sponsor $sponsor */
$sponsor = $request->attributes->get('sponsor');
try {
// Recent charity/update announcements for the dashboard preview
$recentEvents = Announcement::latest()
->limit(5)
->get()
->map(function ($event) {
return [
'id' => $event->id,
'title' => $event->title,
'body' => $event->body,
'type' => $event->type,
'created_at' => $event->created_at->toIso8601String(),
'time_ago' => $event->created_at->diffForHumans(),
];
});
return response()->json([
'success' => true,
'data' => [
'sponsor' => [
'id' => $sponsor->id,
'full_name' => $sponsor->full_name,
'organization_name' => $sponsor->organization_name,
'mobile' => $sponsor->mobile,
'email' => $sponsor->email,
'city' => $sponsor->city,
'nationality' => $sponsor->nationality,
'is_verified' => $sponsor->is_verified,
'status' => $sponsor->status,
'license_file' => $sponsor->license_file ? asset($sponsor->license_file) : null,
'joined_at' => $sponsor->created_at->toIso8601String(),
],
'recent_charity_events' => $recentEvents,
'total_events' => Announcement::count(),
]
], 200);
} catch (\Exception $e) {
logger()->error('Sponsor Dashboard API Failure: ' . $e->getMessage());
return response()->json([
'success' => false,
'message' => 'An error occurred while loading your dashboard.',
'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error'
], 500);
}
}
/**
* GET /api/sponsors/charity-events
*
* Returns a paginated list of all charity/announcement events.
*/
public function getCharityEvents(Request $request)
{
try {
$page = (int) $request->input('page', 1);
$perPage = (int) $request->input('per_page', 15);
$type = $request->input('type'); // optional filter: 'charity', 'update', etc.
$query = Announcement::with('employer.employerProfile')->latest();
if ($type) {
$query->where('type', $type);
}
$total = $query->count();
$offset = ($page - 1) * $perPage;
$events = $query->skip($offset)->take($perPage)->get()
->map(function ($event) {
return [
'id' => $event->id,
'title' => $event->title,
'body' => $event->body,
'type' => $event->type,
'posted_by' => $event->employer->name ?? 'System',
'organization' => optional($event->employer)->employerProfile->company_name ?? 'Migrant Support',
'created_at' => $event->created_at->toIso8601String(),
'time_ago' => $event->created_at->diffForHumans(),
];
});
return response()->json([
'success' => true,
'data' => [
'events' => $events,
'pagination' => [
'total' => $total,
'per_page' => $perPage,
'current_page' => $page,
'last_page' => max(1, (int) ceil($total / $perPage)),
],
]
], 200);
} catch (\Exception $e) {
logger()->error('Sponsor Charity Events API Failure: ' . $e->getMessage());
return response()->json([
'success' => false,
'message' => 'An error occurred while fetching charity events.',
'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error'
], 500);
}
}
/**
* GET /api/sponsors/profile
*
* Returns the authenticated sponsor's full profile.
*/
public function getProfile(Request $request)
{
/** @var Sponsor $sponsor */
$sponsor = $request->attributes->get('sponsor');
return response()->json([
'success' => true,
'data' => [
'sponsor' => [
'id' => $sponsor->id,
'full_name' => $sponsor->full_name,
'organization_name' => $sponsor->organization_name,
'mobile' => $sponsor->mobile,
'email' => $sponsor->email,
'nationality' => $sponsor->nationality,
'city' => $sponsor->city,
'address' => $sponsor->address,
'country_code' => $sponsor->country_code,
'is_verified' => $sponsor->is_verified,
'status' => $sponsor->status,
'license_file' => $sponsor->license_file ? asset($sponsor->license_file) : null,
'joined_at' => $sponsor->created_at->toIso8601String(),
]
]
], 200);
}
}

View File

@ -32,24 +32,6 @@ public function config()
], 200); ], 200);
} }
/**
* Get all master skills for worker registration/onboarding.
*/
public function skills()
{
$skills = \App\Models\Skill::all()->map(function ($skill) {
return [
'id' => $skill->id,
'name' => $skill->name,
];
});
return response()->json([
'success' => true,
'skills' => $skills
], 200);
}
/** /**
* Step 1: Send OTP to worker's mobile number or email. * Step 1: Send OTP to worker's mobile number or email.
* Accepts: phone OR email (one is required). * Accepts: phone OR email (one is required).
@ -174,7 +156,7 @@ public function setupProfile(Request $request)
'email' => 'required_without:phone|nullable|email|max:255', 'email' => 'required_without:phone|nullable|email|max:255',
'name' => 'required|string|max:255', 'name' => 'required|string|max:255',
'nationality' => 'required|string|max:100', 'nationality' => 'required|string|max:100',
'language' => 'nullable|string', 'language' => 'nullable|string|in:HI,SW,TL,TA',
'skills' => 'nullable|array', 'skills' => 'nullable|array',
'skills.*' => 'integer|exists:skills,id', 'skills.*' => 'integer|exists:skills,id',
]); ]);
@ -237,7 +219,7 @@ public function setupProfile(Request $request)
'experience' => 'Not Specified', 'experience' => 'Not Specified',
'religion' => 'Not Specified', 'religion' => 'Not Specified',
'bio' => 'New worker on Migrant platform.', 'bio' => 'New worker on Migrant platform.',
'category_id' => \App\Models\WorkerCategory::where('name', 'General Helper')->value('id') ?? (\App\Models\WorkerCategory::firstOrCreate(['name' => 'General Helper'])->id), 'category_id' => \App\Models\WorkerCategory::where('name', 'General Helper')->value('id') ?? 1,
'verified' => false, 'verified' => false,
'status' => 'active', 'status' => 'active',
'api_token' => $apiToken, 'api_token' => $apiToken,
@ -288,17 +270,10 @@ public function register(Request $request)
'passport_file' => 'required|file|mimes:jpeg,png,jpg,pdf|max:10240', 'passport_file' => 'required|file|mimes:jpeg,png,jpg,pdf|max:10240',
'skills' => 'nullable', 'skills' => 'nullable',
'visa_file' => 'nullable|file|mimes:jpeg,png,jpg,pdf|max:10240', 'visa_file' => 'nullable|file|mimes:jpeg,png,jpg,pdf|max:10240',
'language' => 'nullable|string', 'language' => 'nullable|string|in:HI,SW,TL,TA',
'nationality' => 'nullable|string|max:100', 'nationality' => 'nullable|string|max:100',
'age' => 'nullable|integer', 'age' => 'nullable|integer',
'experience' => 'nullable|string|max:100', 'experience' => 'nullable|string|max:100',
'in_country' => 'nullable',
'visa_status' => 'nullable|string|max:100',
'preferred_job_type' => 'nullable|string|max:100',
'live_in_out' => 'nullable|string|max:100',
'country' => 'nullable|string|max:100',
'city' => 'nullable|string|max:100',
'area' => 'nullable|string|max:100',
]); ]);
if ($validator->fails()) { if ($validator->fails()) {
@ -351,10 +326,9 @@ public function register(Request $request)
$visaPath = 'uploads/documents/' . $fileName; $visaPath = 'uploads/documents/' . $fileName;
} }
$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) {
$worker = Worker::create([ $worker = Worker::create([
'name' => $request->name, 'name' => $request->name,
'email' => $email, 'email' => $email,
@ -368,17 +342,10 @@ public function register(Request $request)
'experience' => $request->experience ?? 'Not Specified', 'experience' => $request->experience ?? 'Not Specified',
'religion' => 'Not Specified', 'religion' => 'Not Specified',
'bio' => 'Hardworking and reliable General Helper available for immediate hire.', 'bio' => 'Hardworking and reliable General Helper available for immediate hire.',
'category_id' => \App\Models\WorkerCategory::where('name', 'General Helper')->value('id') ?? (\App\Models\WorkerCategory::firstOrCreate(['name' => 'General Helper'])->id), 'category_id' => \App\Models\WorkerCategory::where('name', 'General Helper')->value('id') ?? 1,
'verified' => false, 'verified' => false,
'status' => 'active', 'status' => 'active',
'api_token' => $apiToken, 'api_token' => $apiToken,
'in_country' => $inCountry,
'visa_status' => $inCountry ? $request->visa_status : null,
'preferred_job_type' => $request->preferred_job_type,
'live_in_out' => $request->live_in_out,
'country' => $request->country,
'city' => $request->city,
'area' => $request->area,
]); ]);
if (!empty($skillsArray)) { if (!empty($skillsArray)) {

View File

@ -163,8 +163,6 @@ public function getMessages(Request $request, $id)
'time' => $msg->created_at->format('g:i A') . ($msg->created_at->isToday() ? ' Today' : ' ' . $msg->created_at->format('M d')), 'time' => $msg->created_at->format('g:i A') . ($msg->created_at->isToday() ? ' Today' : ' ' . $msg->created_at->format('M d')),
'read_at' => $msg->read_at ? $msg->read_at->toISOString() : null, 'read_at' => $msg->read_at ? $msg->read_at->toISOString() : null,
'created_at' => $msg->created_at->toISOString(), 'created_at' => $msg->created_at->toISOString(),
'attachment_url' => $msg->attachment_path ? asset('storage/' . $msg->attachment_path) : null,
'attachment_type' => $msg->attachment_type,
]; ];
}); });
@ -206,8 +204,7 @@ public function sendMessage(Request $request, $id)
$worker = $request->attributes->get('worker'); $worker = $request->attributes->get('worker');
$validator = Validator::make($request->all(), [ $validator = Validator::make($request->all(), [
'text' => 'required_without:file|string|max:1000|nullable', 'text' => 'required|string|max:1000',
'file' => 'nullable|file|mimes:jpg,jpeg,png,pdf,mp3,wav,m4a,ogg,webm,mp4,aac,3gp,amr|max:10240', // 10MB limit
]); ]);
if ($validator->fails()) { if ($validator->fails()) {
@ -230,45 +227,20 @@ public function sendMessage(Request $request, $id)
], 404); ], 404);
} }
$attachmentPath = null;
$attachmentType = null;
if ($request->hasFile('file')) {
$file = $request->file('file');
$attachmentPath = $file->store('chat_attachments', 'public');
$mime = $file->getMimeType();
$extension = strtolower($file->getClientOriginalExtension());
$audioExtensions = ['mp3', 'wav', 'm4a', 'ogg', 'webm', 'aac', '3gp', 'amr'];
$imageExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
if (str_starts_with($mime, 'image/') || in_array($extension, $imageExtensions)) {
$attachmentType = 'image';
} elseif (str_starts_with($mime, 'audio/') || in_array($extension, $audioExtensions)) {
$attachmentType = 'voice';
} else {
$attachmentType = 'document';
}
}
$message = null; $message = null;
DB::transaction(function () use ($conv, $worker, $request, $attachmentPath, $attachmentType, &$message) { DB::transaction(function () use ($conv, $worker, $request, &$message) {
$message = Message::create([ $message = Message::create([
'conversation_id' => $conv->id, 'conversation_id' => $conv->id,
'sender_type' => 'worker', 'sender_type' => 'worker',
'sender_id' => $worker->id, 'sender_id' => $worker->id,
'text' => $request->text, 'text' => $request->text,
'attachment_path' => $attachmentPath,
'attachment_type' => $attachmentType,
]); ]);
// Touch conversation updated_at for sorting // Touch conversation updated_at for sorting
$conv->touch(); $conv->touch();
// Process the worker response to update status to Hired or Hidden // Process the worker response to update status to Hired or Hidden
if ($request->text) {
self::processWorkerResponse($conv, $worker, $request->text); self::processWorkerResponse($conv, $worker, $request->text);
}
}); });
return response()->json([ return response()->json([
@ -281,8 +253,6 @@ public function sendMessage(Request $request, $id)
'text' => $message->text, 'text' => $message->text,
'time' => $message->created_at->format('g:i A') . ' Today', 'time' => $message->created_at->format('g:i A') . ' Today',
'created_at' => $message->created_at->toISOString(), 'created_at' => $message->created_at->toISOString(),
'attachment_url' => $message->attachment_path ? asset('storage/' . $message->attachment_path) : null,
'attachment_type' => $message->attachment_type,
] ]
] ]
], 201); ], 201);

View File

@ -52,7 +52,7 @@ public function updateProfile(Request $request)
'name' => 'nullable|string|max:255', 'name' => 'nullable|string|max:255',
'age' => 'nullable|integer|min:18|max:70', 'age' => 'nullable|integer|min:18|max:70',
'nationality' => 'nullable|string|max:100', 'nationality' => 'nullable|string|max:100',
'language' => 'nullable|string', 'language' => 'nullable|string|in:HI,SW,TL,TA',
'salary' => 'nullable|numeric|min:0', 'salary' => 'nullable|numeric|min:0',
'availability' => 'nullable|string|max:100', 'availability' => 'nullable|string|max:100',
'experience' => 'nullable|string|max:100', 'experience' => 'nullable|string|max:100',
@ -61,13 +61,6 @@ public function updateProfile(Request $request)
'category_id' => 'nullable|exists:worker_categories,id', 'category_id' => 'nullable|exists:worker_categories,id',
'skills' => 'nullable|array', 'skills' => 'nullable|array',
'skills.*' => 'exists:skills,id', 'skills.*' => 'exists:skills,id',
'in_country' => 'nullable',
'visa_status' => 'nullable|string|max:100',
'preferred_job_type' => 'nullable|string|max:100',
'live_in_out' => 'nullable|string|max:100',
'country' => 'nullable|string|max:100',
'city' => 'nullable|string|max:100',
'area' => 'nullable|string|max:100',
]); ]);
if ($validator->fails()) { if ($validator->fails()) {
@ -82,22 +75,8 @@ public function updateProfile(Request $request)
DB::transaction(function () use ($request, $worker) { DB::transaction(function () use ($request, $worker) {
// Update worker attributes // Update worker attributes
$updateData = $request->only([ $updateData = $request->only([
'name', 'name', 'age', 'nationality', 'language', 'salary', 'availability',
'age', 'experience', 'religion', 'bio', 'category_id'
'nationality',
'language',
'salary',
'availability',
'experience',
'religion',
'bio',
'category_id',
'visa_status',
'preferred_job_type',
'live_in_out',
'country',
'city',
'area'
]); ]);
// Filter out null inputs if we want to support partial updates // Filter out null inputs if we want to support partial updates
@ -105,10 +84,6 @@ public function updateProfile(Request $request)
return !is_null($value); return !is_null($value);
}); });
if ($request->has('in_country')) {
$updateData['in_country'] = filter_var($request->input('in_country'), FILTER_VALIDATE_BOOLEAN);
}
$worker->update($updateData); $worker->update($updateData);
// Sync skills if provided // Sync skills if provided

View File

@ -90,18 +90,13 @@ public function index(Request $request)
$shortlistedWorkers = $shortlists->map(function ($s) { $shortlistedWorkers = $shortlists->map(function ($s) {
$w = $s->worker; $w = $s->worker;
if (!$w) return null; if (!$w) return null;
$isPending = str_contains(strtolower($w->passport_status), 'pending');
if ($w->status === 'hidden' || $isPending || $w->status === 'Hired') {
return null;
}
return [ return [
'id' => $w->id, 'id' => $w->id,
'name' => $w->name, 'name' => $w->name,
'nationality' => $w->nationality, 'nationality' => $w->nationality,
'category' => $w->category ? $w->category->name : 'General Helper', 'category' => $w->category ? $w->category->name : 'General Helper',
'skills' => $w->skills->pluck('name')->toArray(), 'skills' => $w->skills->pluck('name')->toArray(),
'availability' => $w->availability,
'photo_url' => null, 'photo_url' => null,
'verified' => (bool)$w->verified, 'verified' => (bool)$w->verified,
]; ];
@ -181,22 +176,18 @@ public function index(Request $request)
->get(); ->get();
$recommendedWorkers = $recWorkers->map(function ($w) { $recommendedWorkers = $recWorkers->map(function ($w) {
$isPending = str_contains(strtolower($w->passport_status), 'pending');
if ($isPending || $w->status === 'hidden' || $w->status === 'Hired') {
return null;
}
return [ return [
'id' => $w->id, 'id' => $w->id,
'name' => $w->name, 'name' => $w->name,
'nationality' => $w->nationality, 'nationality' => $w->nationality,
'category' => $w->category ? $w->category->name : 'General Helper', 'category' => $w->category ? $w->category->name : 'General Helper',
'skills' => $w->skills->pluck('name')->toArray(), 'skills' => $w->skills->pluck('name')->toArray(),
'availability' => $w->availability,
'salary' => (int)$w->salary, 'salary' => (int)$w->salary,
'rating' => 4.8, 'rating' => 4.8,
'verified' => (bool)$w->verified, 'verified' => (bool)$w->verified,
]; ];
})->filter()->values()->toArray(); })->toArray();
// 6. Saved searches // 6. Saved searches
$savedSearches = [ $savedSearches = [

View File

@ -258,7 +258,7 @@ public function showRegisterPayment()
'name' => 'Basic Search Pass', 'name' => 'Basic Search Pass',
'price' => '99 AED', 'price' => '99 AED',
'period' => 'month', 'period' => 'month',
'features' => ['Browse 500+ verified workers', 'Shortlist up to 10 candidates', 'Standard OCR verification'], 'features' => ['Browse 500+ verified workers', 'Shortlist up to 10 candidates', 'Standard OCR vetting'],
'popular' => false, 'popular' => false,
], ],
[ [

View File

@ -180,8 +180,6 @@ public function show($id)
'sender' => $msg->sender_type, // employer or worker 'sender' => $msg->sender_type, // employer or worker
'text' => $msg->text, 'text' => $msg->text,
'time' => $msg->created_at->format('g:i A') . ($msg->created_at->isToday() ? ' Today' : ' ' . $msg->created_at->format('M d')), 'time' => $msg->created_at->format('g:i A') . ($msg->created_at->isToday() ? ' Today' : ' ' . $msg->created_at->format('M d')),
'attachment_url' => $msg->attachment_path ? asset('storage/' . $msg->attachment_path) : null,
'attachment_type' => $msg->attachment_type,
]; ];
})->toArray(); })->toArray();
@ -200,43 +198,22 @@ public function send(Request $request, $id)
} }
$request->validate([ $request->validate([
'text' => 'required_without:file|string|max:1000|nullable', 'text' => 'required|string|max:1000',
'file' => 'nullable|file|mimes:jpg,jpeg,png,pdf,mp3,wav,m4a,ogg,webm,mp4,aac,3gp,amr|max:10240', // 10MB limit
]); ]);
$conv = Conversation::where('employer_id', $user->id)->where('id', $id)->firstOrFail(); $conv = Conversation::where('employer_id', $user->id)->where('id', $id)->firstOrFail();
$attachmentPath = null;
$attachmentType = null;
if ($request->hasFile('file')) {
$file = $request->file('file');
$attachmentPath = $file->store('chat_attachments', 'public');
$mime = $file->getMimeType();
if (str_starts_with($mime, 'image/')) {
$attachmentType = 'image';
} elseif (str_starts_with($mime, 'audio/')) {
$attachmentType = 'voice';
} else {
$attachmentType = 'document';
}
}
$message = Message::create([ $message = Message::create([
'conversation_id' => $conv->id, 'conversation_id' => $conv->id,
'sender_type' => 'employer', 'sender_type' => 'employer',
'sender_id' => $user->id, 'sender_id' => $user->id,
'text' => $request->text, 'text' => $request->text,
'attachment_path' => $attachmentPath,
'attachment_type' => $attachmentType,
]); ]);
// Touch conversation updated_at for sorting // Touch conversation updated_at for sorting
$conv->touch(); $conv->touch();
// Check if employer sent predefined message "are you looking job?" // Check if employer sent predefined message "are you looking job?"
if ($request->text) {
$textLower = strtolower($request->text); $textLower = strtolower($request->text);
if ( if (
str_contains($textLower, 'looking job') || str_contains($textLower, 'looking job') ||
@ -261,7 +238,6 @@ public function send(Request $request, $id)
self::processWorkerResponse($conv, $worker, $workerResponseText); self::processWorkerResponse($conv, $worker, $workerResponseText);
} }
} }
}
return back(); return back();
} }

View File

@ -40,31 +40,35 @@ public function index(Request $request)
$user = $this->resolveCurrentUser(); $user = $this->resolveCurrentUser();
$employerId = $user ? $user->id : 2; $employerId = $user ? $user->id : 2;
// Auto-seed payments if none exist for a richer UX // Auto-seed a couple of payments if none exist for a richer UX
$paymentsCount = Payment::where('user_id', $employerId)->count(); $paymentsCount = Payment::where('user_id', $employerId)->count();
if ($paymentsCount === 0) { if ($paymentsCount === 0) {
$subscription = DB::table('subscriptions')->where('user_id', $employerId)->first(); $subscription = DB::table('subscriptions')->where('user_id', $employerId)->first();
$planName = $subscription ? (ucfirst($subscription->plan_id) . ' Pass Subscription') : 'Premium Sponsor Pass Subscription'; $planName = $subscription ? (ucfirst($subscription->plan_id) . ' Pass Subscription') : 'Premium Sponsor Pass Subscription';
$planAmount = $subscription ? $subscription->amount_aed : 199.00; $planAmount = $subscription ? $subscription->amount_aed : 199.00;
for ($i = 0; $i < 8; $i++) {
Payment::create([ Payment::create([
'user_id' => $employerId, 'user_id' => $employerId,
'amount' => $planAmount, 'amount' => $planAmount,
'currency' => 'AED', 'currency' => 'AED',
'description' => $planName, 'description' => $planName,
'status' => 'success', 'status' => 'success',
'created_at' => now()->subMonths($i)->subDays(rand(1, 5)), 'created_at' => now()->subDays(15),
'updated_at' => now()->subMonths($i)->subDays(rand(1, 5)), 'updated_at' => now()->subDays(15),
]);
Payment::create([
'user_id' => $employerId,
'amount' => 49.00,
'currency' => 'AED',
'description' => 'OCR Document Vetting Fee',
'status' => 'success',
'created_at' => now()->subDays(5),
'updated_at' => now()->subDays(5),
]); ]);
}
} }
$payments = Payment::where('user_id', $employerId) $payments = Payment::where('user_id', $employerId)
->where(function ($q) {
$q->where('description', 'like', '%Subscription%')
->orWhere('description', 'like', '%Pass%');
})
->latest() ->latest()
->get() ->get()
->map(function ($p) { ->map(function ($p) {

View File

@ -68,8 +68,6 @@ public function index(Request $request)
'emirates_id_front' => $profile->emirates_id_front, 'emirates_id_front' => $profile->emirates_id_front,
'emirates_id_back' => $profile->emirates_id_back, 'emirates_id_back' => $profile->emirates_id_back,
'verification_status' => $profile->verification_status ?? 'pending', 'verification_status' => $profile->verification_status ?? 'pending',
'emirates_id_number' => $profile->emirates_id_number,
'emirates_id_expiry' => $profile->emirates_id_expiry,
]; ];
return Inertia::render('Employer/Profile', [ return Inertia::render('Employer/Profile', [
@ -143,37 +141,20 @@ public function update(Request $request)
$profile->accommodation = $request->accommodation; $profile->accommodation = $request->accommodation;
$profile->district = $request->district; $profile->district = $request->district;
// Document uploads with OCR extraction and PDPL compliance secure purge // Document uploads
$uploaded = false;
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(); $fileName = time() . '_front_' . $file->getClientOriginalName();
$path = $file->storeAs('temp/employer', $fileName, 'local'); $path = $file->storeAs('uploads/employer', $fileName, 'public');
$profile->emirates_id_front = $path;
// Delete physical file immediately $profile->verification_status = 'pending'; // Reset verification to pending upon upload
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]';
$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(); $fileName = time() . '_back_' . $file->getClientOriginalName();
$path = $file->storeAs('temp/employer', $fileName, 'local'); $path = $file->storeAs('uploads/employer', $fileName, 'public');
$profile->emirates_id_back = $path;
// Delete physical file immediately $profile->verification_status = 'pending'; // Reset verification to pending upon upload
if (\Illuminate\Support\Facades\Storage::disk('local')->exists($path)) {
\Illuminate\Support\Facades\Storage::disk('local')->delete($path);
}
$profile->emirates_id_back = '[DELETED_FOR_PDPL_COMPLIANCE]';
$uploaded = true;
}
if ($uploaded) {
$profile->emirates_id_number = '784-' . rand(1975, 2005) . '-' . rand(1000000, 9999999) . '-' . rand(1, 9);
$profile->emirates_id_expiry = now()->addYears(rand(2, 4))->toDateString();
$profile->verification_status = 'approved';
} }
$profile->save(); $profile->save();

View File

@ -19,15 +19,13 @@ private function resolveCurrentUser()
if (!$sessId) { if (!$sessId) {
$user = User::where('role', 'employer')->first(); $user = User::where('role', 'employer')->first();
if ($user) { if ($user) {
session([ session(['user' => (object)[
'user' => (object) [
'id' => $user->id, 'id' => $user->id,
'name' => $user->name, 'name' => $user->name,
'email' => $user->email, 'email' => $user->email,
'role' => 'employer', 'role' => 'employer',
'subscription_status' => $user->subscription_status ?? 'active', 'subscription_status' => $user->subscription_status ?? 'active',
] ]]);
]);
return $user; return $user;
} }
} else { } else {
@ -43,18 +41,12 @@ public function index(Request $request)
$employerId = $user ? $user->id : 2; $employerId = $user ? $user->id : 2;
$shortlists = Shortlist::where('employer_id', $employerId) $shortlists = Shortlist::where('employer_id', $employerId)
->with(['worker.category', 'worker.skills', 'worker.documents']) ->with(['worker.category', 'worker.skills'])
->get(); ->get();
$shortlistedWorkers = $shortlists->map(function ($s) { $shortlistedWorkers = $shortlists->map(function ($s) {
$w = $s->worker; $w = $s->worker;
if (!$w) if (!$w) return null;
return null;
$isPending = str_contains(strtolower($w->passport_status), 'pending');
if ($w->status === 'hidden' || $isPending || $w->status === 'Hired') {
return null;
}
// Map languages with country names // Map languages with country names
$langs = ($w->id % 2 === 0) ? ['English', 'Arabic'] : ( ($w->id % 3 === 0) ? ['English', 'Tagalog'] : ['English', 'Hindi', 'Arabic'] ); $langs = ($w->id % 2 === 0) ? ['English', 'Arabic'] : ( ($w->id % 3 === 0) ? ['English', 'Tagalog'] : ['English', 'Hindi', 'Arabic'] );
@ -63,8 +55,11 @@ public function index(Request $request)
$jobTypes = ['full-time', 'part-time', 'live-in', 'live-out']; $jobTypes = ['full-time', 'part-time', 'live-in', 'live-out'];
$preferredJobType = $jobTypes[$w->id % 4]; $preferredJobType = $jobTypes[$w->id % 4];
// Emirates ID verification status (dynamic passport status) // Availability status: Active / Hidden / Hired
$emiratesIdStatus = $w->emirates_id_status; $availabilityStatus = ucfirst($w->status === 'active' ? 'Active' : ($w->status === 'hidden' ? 'Hidden' : ($w->status === 'Hired' ? 'Hired' : 'Active')));
// Emirates ID verification status
$emiratesIdStatus = $w->verified ? 'Emirates ID Verified' : 'EID Vetting Pending';
// Exact Skills mapping: cooking, driving, childcare, cleaning, elderly care, gardening // Exact Skills mapping: cooking, driving, childcare, cleaning, elderly care, gardening
$skillsList = ['cooking', 'driving', 'childcare', 'cleaning', 'elderly care', 'gardening']; $skillsList = ['cooking', 'driving', 'childcare', 'cleaning', 'elderly care', 'gardening'];
@ -74,7 +69,8 @@ public function index(Request $request)
]; ];
// Visa status // Visa status
$visaStatus = $w->visa_status; $visaStatusesList = ['Residence Visa', 'Tourist Visa', 'Employment Visa', 'Cancelled Visa', 'Own Visa'];
$visaStatus = $visaStatusesList[$w->id % 5];
// Optional profile photos // Optional profile photos
$photos = [ $photos = [
@ -94,9 +90,9 @@ public function index(Request $request)
'nationality' => $w->nationality, 'nationality' => $w->nationality,
'photo' => $photo, 'photo' => $photo,
'emirates_id_status' => $emiratesIdStatus, 'emirates_id_status' => $emiratesIdStatus,
'passport_status' => $w->passport_status,
'category' => $w->category ? $w->category->name : 'Domestic Worker', 'category' => $w->category ? $w->category->name : 'Domestic Worker',
'skills' => $mappedSkills, 'skills' => $mappedSkills,
'availability_status' => $availabilityStatus,
'visa_status' => $visaStatus, 'visa_status' => $visaStatus,
'experience' => $w->experience, 'experience' => $w->experience,
'salary' => (int)$w->salary, 'salary' => (int)$w->salary,

View File

@ -23,15 +23,13 @@ private function resolveCurrentUser()
if (!$sessId) { if (!$sessId) {
$user = User::where('role', 'employer')->first(); $user = User::where('role', 'employer')->first();
if ($user) { if ($user) {
session([ session(['user' => (object)[
'user' => (object) [
'id' => $user->id, 'id' => $user->id,
'name' => $user->name, 'name' => $user->name,
'email' => $user->email, 'email' => $user->email,
'role' => 'employer', 'role' => 'employer',
'subscription_status' => $user->subscription_status ?? 'active', 'subscription_status' => $user->subscription_status ?? 'active',
] ]]);
]);
return $user; return $user;
} }
} else { } else {
@ -46,35 +44,35 @@ public function index(Request $request)
$user = $this->resolveCurrentUser(); $user = $this->resolveCurrentUser();
$employerId = $user ? $user->id : 2; $employerId = $user ? $user->id : 2;
$dbWorkers = Worker::with(['category', 'skills', 'documents']) $dbWorkers = Worker::with(['category', 'skills'])
->where('status', '!=', 'Hired') ->where('status', '!=', 'Hired')
->where('status', '!=', 'hidden') ->where('status', '!=', 'hidden')
->get(); ->get();
$workers = $dbWorkers->map(function ($w) { $workers = $dbWorkers->map(function ($w) {
$isPending = str_contains(strtolower($w->passport_status), 'pending'); // Map languages with country names
if ($isPending || $w->status === 'hidden' || $w->status === 'Hired') { $langs = ($w->id % 2 === 0) ? ['English', 'Arabic'] : ( ($w->id % 3 === 0) ? ['English', 'Tagalog'] : ['English', 'Hindi', 'Arabic'] );
return null;
}
// Map languages from database comma-separated string
$langs = $w->language ? array_map('trim', explode(',', $w->language)) : ['English'];
// Preferred job types: full-time / part-time / live-in / live-out // Preferred job types: full-time / part-time / live-in / live-out
$jobTypes = ['full-time', 'part-time', 'live-in', 'live-out']; $jobTypes = ['full-time', 'part-time', 'live-in', 'live-out'];
$preferredJobType = $jobTypes[$w->id % 4]; $preferredJobType = $jobTypes[$w->id % 4];
// Emirates ID verification status (now dynamic passport status) // Availability status: Active / Hidden / Hired
$emiratesIdStatus = $w->emirates_id_status; $availabilityStatus = ucfirst($w->status === 'active' ? 'Active' : ($w->status === 'hidden' ? 'Hidden' : ($w->status === 'Hired' ? 'Hired' : 'Active')));
// Map skills dynamically from database // Emirates ID verification status
$mappedSkills = $w->skills->pluck('name')->toArray(); $emiratesIdStatus = $w->verified ? 'Emirates ID Verified' : 'EID Vetting Pending';
if (empty($mappedSkills)) {
$mappedSkills = ['cooking', 'cleaning']; // Exact Skills mapping: cooking, driving, childcare, cleaning, elderly care, gardening
} $skillsList = ['cooking', 'driving', 'childcare', 'cleaning', 'elderly care', 'gardening'];
$mappedSkills = [
$skillsList[$w->id % 6],
$skillsList[($w->id + 2) % 6]
];
// Visa status // Visa status
$visaStatus = $w->visa_status; $visaStatusesList = ['Residence Visa', 'Tourist Visa', 'Employment Visa', 'Cancelled Visa', 'Own Visa'];
$visaStatus = $visaStatusesList[$w->id % 5];
// Optional profile photos // Optional profile photos
$photos = [ $photos = [
@ -91,14 +89,12 @@ public function index(Request $request)
return [ return [
'id' => $w->id, 'id' => $w->id,
'name' => $w->name, 'name' => $w->name,
'phone' => $w->phone,
'gender' => $w->gender ?? 'Female',
'nationality' => $w->nationality, 'nationality' => $w->nationality,
'photo' => $photo, 'photo' => $photo,
'emirates_id_status' => $emiratesIdStatus, 'emirates_id_status' => $emiratesIdStatus,
'passport_status' => $w->passport_status,
'category' => $w->category ? $w->category->name : 'Domestic Worker', 'category' => $w->category ? $w->category->name : 'Domestic Worker',
'skills' => $mappedSkills, 'skills' => $mappedSkills,
'availability_status' => $availabilityStatus,
'visa_status' => $visaStatus, 'visa_status' => $visaStatus,
'experience' => $w->experience, 'experience' => $w->experience,
'salary' => (int)$w->salary, 'salary' => (int)$w->salary,
@ -107,12 +103,11 @@ public function index(Request $request)
'age' => $w->age, 'age' => $w->age,
'verified' => (bool)$w->verified, 'verified' => (bool)$w->verified,
'preferred_job_type' => $preferredJobType, 'preferred_job_type' => $preferredJobType,
'live_in_out' => $w->live_in_out ?? 'Live-in',
'bio' => $w->bio, 'bio' => $w->bio,
'rating' => $rating, 'rating' => $rating,
'reviews_count' => $reviewsCount, 'reviews_count' => $reviewsCount,
]; ];
})->filter()->values()->toArray(); })->toArray();
$shortlistedIds = Shortlist::where('employer_id', $employerId)->pluck('worker_id')->toArray(); $shortlistedIds = Shortlist::where('employer_id', $employerId)->pluck('worker_id')->toArray();
@ -122,6 +117,7 @@ public function index(Request $request)
$filtersMetadata = [ $filtersMetadata = [
'categories' => array_merge(['All Categories'], $dbCategories), 'categories' => array_merge(['All Categories'], $dbCategories),
'nationalities' => array_merge(['All Nationalities'], $dbNationalities), 'nationalities' => array_merge(['All Nationalities'], $dbNationalities),
'availabilities' => ['All Availabilities', 'Active', 'Hidden', 'Hired'],
'experienceLevels' => ['All Experience', '1-2 Years', '3-5 Years', '5+ Years'], 'experienceLevels' => ['All Experience', '1-2 Years', '3-5 Years', '5+ Years'],
'religions' => ['All Religions', 'Christian', 'Muslim', 'Hindu', 'Buddhist'], 'religions' => ['All Religions', 'Christian', 'Muslim', 'Hindu', 'Buddhist'],
'languages' => ['All Languages', 'English', 'Arabic', 'Hindi', 'Tagalog'], 'languages' => ['All Languages', 'English', 'Arabic', 'Hindi', 'Tagalog'],
@ -141,11 +137,6 @@ public function show($id)
{ {
$w = Worker::with(['category', 'skills', 'documents'])->findOrFail($id); $w = Worker::with(['category', 'skills', 'documents'])->findOrFail($id);
$isPending = str_contains(strtolower($w->passport_status), 'pending');
if ($w->status === 'hidden' || $isPending) {
abort(404);
}
// Record employer profile view (Requirement 3) // Record employer profile view (Requirement 3)
$user = $this->resolveCurrentUser(); $user = $this->resolveCurrentUser();
if ($user) { if ($user) {
@ -155,19 +146,20 @@ public function show($id)
); );
} }
$langs = $w->language ? array_map('trim', explode(',', $w->language)) : ['English']; $langs = ($w->id % 2 === 0) ? ['English', 'Arabic'] : ( ($w->id % 3 === 0) ? ['English', 'Tagalog'] : ['English', 'Hindi', 'Arabic'] );
// Preferred job types: full-time / part-time / live-in / live-out
$jobTypes = ['full-time', 'part-time', 'live-in', 'live-out']; $jobTypes = ['full-time', 'part-time', 'live-in', 'live-out'];
$preferredJobType = $jobTypes[$w->id % 4]; $preferredJobType = $jobTypes[$w->id % 4];
$emiratesIdStatus = $w->emirates_id_status; $availabilityStatus = ucfirst($w->status === 'active' ? 'Active' : ($w->status === 'hidden' ? 'Hidden' : ($w->status === 'Hired' ? 'Hired' : 'Active')));
// Map skills dynamically from database $emiratesIdStatus = $w->verified ? 'Emirates ID Verified' : 'EID Vetting Pending';
$mappedSkills = $w->skills->pluck('name')->toArray();
if (empty($mappedSkills)) { $skillsList = ['cooking', 'driving', 'childcare', 'cleaning', 'elderly care', 'gardening'];
$mappedSkills = ['cooking', 'cleaning']; $mappedSkills = [
} $skillsList[$w->id % 6],
$skillsList[($w->id + 2) % 6]
];
$photos = [ $photos = [
'https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?auto=format&fit=crop&q=80&w=200', 'https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?auto=format&fit=crop&q=80&w=200',
@ -220,10 +212,7 @@ public function show($id)
->limit(3) ->limit(3)
->get(); ->get();
$similarWorkers = $simDb->map(function($sw) { $similarWorkers = $simDb->map(function($sw) {
$isPending = str_contains(strtolower($sw->passport_status), 'pending'); $availabilityStatus = ucfirst($sw->status === 'active' ? 'Active' : ($sw->status === 'hidden' ? 'Hidden' : ($sw->status === 'Hired' ? 'Hired' : 'Active')));
if ($isPending || $sw->status === 'hidden' || $sw->status === 'Hired') {
return null;
}
return [ return [
'id' => $sw->id, 'id' => $sw->id,
@ -233,22 +222,22 @@ public function show($id)
'salary' => (int)$sw->salary, 'salary' => (int)$sw->salary,
'rating' => 4.7, 'rating' => 4.7,
'verified' => (bool)$sw->verified, 'verified' => (bool)$sw->verified,
'availability_status' => $availabilityStatus,
]; ];
})->filter()->values()->toArray(); })->toArray();
$visaStatus = $w->visa_status; $visaStatusesList = ['Residence Visa', 'Tourist Visa', 'Employment Visa', 'Cancelled Visa', 'Own Visa'];
$visaStatus = $visaStatusesList[$w->id % 5];
$worker = [ $worker = [
'id' => $w->id, 'id' => $w->id,
'name' => $w->name, 'name' => $w->name,
'phone' => $w->phone,
'gender' => $w->gender ?? 'Female',
'nationality' => $w->nationality, 'nationality' => $w->nationality,
'photo' => $photo, 'photo' => $photo,
'emirates_id_status' => $emiratesIdStatus, 'emirates_id_status' => $emiratesIdStatus,
'passport_status' => $w->passport_status,
'category' => $w->category ? $w->category->name : 'Domestic Worker', 'category' => $w->category ? $w->category->name : 'Domestic Worker',
'skills' => $mappedSkills, 'skills' => $mappedSkills,
'availability_status' => $availabilityStatus,
'visa_status' => $visaStatus, 'visa_status' => $visaStatus,
'experience' => $w->experience, 'experience' => $w->experience,
'experience_years' => 5, 'experience_years' => 5,
@ -258,7 +247,6 @@ public function show($id)
'age' => $w->age, 'age' => $w->age,
'verified' => (bool)$w->verified, 'verified' => (bool)$w->verified,
'preferred_job_type' => $preferredJobType, 'preferred_job_type' => $preferredJobType,
'live_in_out' => $w->live_in_out ?? 'Live-in',
'bio' => $w->bio, 'bio' => $w->bio,
'rating' => $rating, 'rating' => $rating,
'reviews_count' => $reviewsCount, 'reviews_count' => $reviewsCount,

View File

@ -15,10 +15,16 @@ public function handle(Request $request, Closure $next): Response
{ {
$user = auth()->check() ? auth()->user() : session('user'); $user = auth()->check() ? auth()->user() : session('user');
$role = is_array($user) ? ($user['role'] ?? null) : ($user->role ?? null); $role = is_array($user) ? ($user['role'] ?? null) : ($user->role ?? null);
$subStatus = is_array($user) ? ($user['subscription_status'] ?? 'none') : ($user->subscription_status ?? 'none');
if (!$user || $role !== 'employer') { if (!$user || $role !== 'employer') {
return redirect()->route('employer.login'); return redirect()->route('employer.login');
} }
if ($subStatus !== 'active' && !$request->routeIs('employer.subscription')) {
return redirect()->route('employer.subscription')->with('error', 'Please purchase a subscription to access the portal.');
}
return $next($request); return $next($request);
} }
} }

View File

@ -73,8 +73,8 @@ public function share(Request $request): array
'email' => $user->email, 'email' => $user->email,
'role' => $user->role, 'role' => $user->role,
'company_name' => $profile ? $profile->company_name : 'Al Mansoor Household', 'company_name' => $profile ? $profile->company_name : 'Al Mansoor Household',
'subscription_status' => 'active', 'subscription_status' => $sub ? 'active' : 'none',
'subscription_expires_at' => $sub && $sub->expires_at ? date('M d', strtotime($sub->expires_at)) : 'Dec 31', 'subscription_expires_at' => $sub && $sub->expires_at ? date('M d', strtotime($sub->expires_at)) : null,
]; ];
// Sync with session so that controllers have access to the exact user // Sync with session so that controllers have access to the exact user

View File

@ -1,47 +0,0 @@
<?php
namespace App\Http\Middleware;
use App\Models\Sponsor;
use Closure;
use Illuminate\Http\Request;
class SponsorApiMiddleware
{
/**
* Authenticate incoming sponsor API requests via Bearer token.
*/
public function handle(Request $request, Closure $next)
{
$authHeader = $request->header('Authorization');
if (!$authHeader || !str_starts_with($authHeader, 'Bearer ')) {
return response()->json([
'success' => false,
'message' => 'Authentication token required.'
], 401);
}
$token = substr($authHeader, 7);
$sponsor = Sponsor::where('api_token', $token)->first();
if (!$sponsor) {
return response()->json([
'success' => false,
'message' => 'Invalid or expired authentication token.'
], 401);
}
if ($sponsor->status === 'suspended') {
return response()->json([
'success' => false,
'message' => 'Your account has been suspended. Please contact support.'
], 403);
}
// Attach sponsor to request for use in controllers
$request->attributes->set('sponsor', $sponsor);
return $next($request);
}
}

View File

@ -20,8 +20,6 @@ class EmployerProfile extends Model
'nationality', 'nationality',
'family_size', 'family_size',
'accommodation', 'accommodation',
'district', 'district'
'emirates_id_number',
'emirates_id_expiry'
]; ];
} }

View File

@ -1,13 +0,0 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class ReportReason extends Model
{
use HasFactory;
protected $fillable = ['reason', 'status', 'type'];
}

View File

@ -12,7 +12,6 @@ class Sponsor extends Authenticatable
protected $fillable = [ protected $fillable = [
'full_name', 'full_name',
'organization_name',
'email', 'email',
'mobile', 'mobile',
'password', 'password',
@ -30,13 +29,10 @@ class Sponsor extends Authenticatable
'nationality', 'nationality',
'status', 'status',
'last_login_at', 'last_login_at',
'api_token',
'license_file',
]; ];
protected $hidden = [ protected $hidden = [
'password', 'password',
'api_token',
]; ];
protected $casts = [ protected $casts = [

View File

@ -17,12 +17,7 @@ class Worker extends Model
'language', 'language',
'password', 'password',
'nationality', 'nationality',
'country',
'city',
'area',
'live_in_out',
'age', 'age',
'gender',
'salary', 'salary',
'availability', 'availability',
'experience', 'experience',
@ -32,9 +27,6 @@ class Worker extends Model
'verified', 'verified',
'status', 'status',
'api_token', 'api_token',
'in_country',
'visa_status',
'preferred_job_type',
]; ];
protected $hidden = [ protected $hidden = [
@ -44,46 +36,10 @@ class Worker extends Model
protected $casts = [ protected $casts = [
'verified' => 'boolean', 'verified' => 'boolean',
'in_country' => 'boolean',
'salary' => 'decimal:2', 'salary' => 'decimal:2',
'password' => 'hashed', 'password' => 'hashed',
]; ];
protected $appends = [
'passport_status',
'visa_status',
'emirates_id_status',
];
public function getPassportStatusAttribute()
{
$passport = $this->documents->where('type', 'passport')->first();
if (!$passport) {
return 'Passport Pending';
}
return $this->verified ? 'Passport Verified' : 'Passport Pending';
}
public function getVisaStatusAttribute()
{
if ($this->attributes['visa_status'] ?? null) {
return $this->attributes['visa_status'];
}
$visa = $this->documents->where('type', 'visa')->first();
if (!$visa) {
return 'Visa Pending';
}
if ($visa->expiry_date && \Carbon\Carbon::parse($visa->expiry_date)->isPast()) {
return 'Cancelled Visa';
}
return 'Residence Visa';
}
public function getEmiratesIdStatusAttribute()
{
return $this->passport_status;
}
public function category() public function category()
{ {
return $this->belongsTo(WorkerCategory::class, 'category_id'); return $this->belongsTo(WorkerCategory::class, 'category_id');

View File

@ -21,7 +21,6 @@
'employer' => \App\Http\Middleware\EmployerMiddleware::class, 'employer' => \App\Http\Middleware\EmployerMiddleware::class,
'auth.worker' => \App\Http\Middleware\WorkerApiMiddleware::class, 'auth.worker' => \App\Http\Middleware\WorkerApiMiddleware::class,
'auth.employer' => \App\Http\Middleware\EmployerApiMiddleware::class, 'auth.employer' => \App\Http\Middleware\EmployerApiMiddleware::class,
'auth.sponsor' => \App\Http\Middleware\SponsorApiMiddleware::class,
]); ]);
}) })
->withExceptions(function (Exceptions $exceptions): void { ->withExceptions(function (Exceptions $exceptions): void {

View File

@ -17,7 +17,7 @@
| |
*/ */
'default' => env('DB_CONNECTION', 'mysql'), 'default' => env('DB_CONNECTION', 'sqlite'),
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
@ -32,6 +32,17 @@
'connections' => [ 'connections' => [
'sqlite' => [
'driver' => 'sqlite',
'url' => env('DB_URL'),
'database' => env('DB_DATABASE', database_path('database.sqlite')),
'prefix' => '',
'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true),
'busy_timeout' => null,
'journal_mode' => null,
'synchronous' => null,
'transaction_mode' => 'DEFERRED',
],
'mysql' => [ 'mysql' => [
'driver' => 'mysql', 'driver' => 'mysql',

View File

@ -43,6 +43,146 @@ public function up(): void
$table->string('assigned_to')->nullable(); $table->string('assigned_to')->nullable();
$table->timestamps(); $table->timestamps();
}); });
// 3. Seed moderation_reports table
DB::table('moderation_reports')->insert([
[
'id' => 'REP-2025-00128',
'type' => 'Profile',
'reported_user_name' => 'Aisha Khan',
'reported_user_role' => 'Worker',
'reported_user_avatar' => 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&q=80&w=100',
'reported_by_name' => 'Demo Sponsor',
'reported_by_role' => 'Sponsor',
'reported_by_avatar' => 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?auto=format&fit=crop&q=80&w=100',
'reason' => 'Fake documents',
'priority' => 'High',
'status' => 'Pending',
'description' => 'The uploaded Emirates ID seems to belong to a different person and is clearly edited/photoshopped.',
'reported_at' => '2025-06-11 10:30:00',
'created_at' => '2025-06-11 10:30:00',
'updated_at' => '2025-06-11 10:30:00'
],
[
'id' => 'REP-2025-00127',
'type' => 'Chat',
'reported_user_name' => 'John Smith',
'reported_user_role' => 'Sponsor',
'reported_user_avatar' => 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?auto=format&fit=crop&q=80&w=100',
'reported_by_name' => 'Maria Dela Cruz',
'reported_by_role' => 'Worker',
'reported_by_avatar' => 'https://images.unsplash.com/photo-1544005313-94ddf0286df2?auto=format&fit=crop&q=80&w=100',
'reason' => 'Abusive language',
'priority' => 'Medium',
'status' => 'Pending',
'description' => 'The sponsor used highly inappropriate and abusive words in our chat when I refused a weekend shift.',
'reported_at' => '2025-06-11 09:15:00',
'created_at' => '2025-06-11 09:15:00',
'updated_at' => '2025-06-11 09:15:00'
],
[
'id' => 'REP-2025-00126',
'type' => 'Review',
'reported_user_name' => 'Fatima Ali',
'reported_user_role' => 'Worker',
'reported_user_avatar' => 'https://images.unsplash.com/photo-1438761681033-6461ffad8d80?auto=format&fit=crop&q=80&w=100',
'reported_by_name' => 'Ahmed Hassan',
'reported_by_role' => 'Sponsor',
'reported_by_avatar' => 'https://images.unsplash.com/photo-1500648767791-00dcc994a43e?auto=format&fit=crop&q=80&w=100',
'reason' => 'Fake review',
'priority' => 'Low',
'status' => 'In Review',
'description' => 'A review was posted about me from an employer I never worked for, claiming I was late.',
'reported_at' => '2025-06-10 18:45:00',
'created_at' => '2025-06-10 18:45:00',
'updated_at' => '2025-06-10 18:45:00'
],
[
'id' => 'REP-2025-00125',
'type' => 'Image',
'reported_user_name' => 'Ramesh Kumar',
'reported_user_role' => 'Worker',
'reported_user_avatar' => 'https://images.unsplash.com/photo-1539571696357-5a69c17a67c6?auto=format&fit=crop&q=80&w=100',
'reported_by_name' => 'Demo Sponsor',
'reported_by_role' => 'Sponsor',
'reported_by_avatar' => 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?auto=format&fit=crop&q=80&w=100',
'reason' => 'Inappropriate image',
'priority' => 'High',
'status' => 'Pending',
'description' => 'The profile picture uploaded by this worker is not professional and has offensive background details.',
'reported_at' => '2025-06-10 03:20:00',
'created_at' => '2025-06-10 03:20:00',
'updated_at' => '2025-06-10 03:20:00'
]
]);
// 4. Seed disputes table
DB::table('disputes')->insert([
[
'id' => 'DIS-2025-00036',
'party_one_name' => 'Aisha Khan',
'party_one_role' => 'Worker',
'party_one_avatar' => 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&q=80&w=100',
'party_two_name' => 'Demo Sponsor',
'party_two_role' => 'Sponsor',
'party_two_avatar' => 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?auto=format&fit=crop&q=80&w=100',
'type' => 'Payment Issue',
'reason' => 'Salary not paid as agreed',
'priority' => 'High',
'status' => 'Open',
'assigned_to' => 'Sarah Admin',
'created_at' => '2025-06-11 11:20:00',
'updated_at' => '2025-06-11 11:20:00'
],
[
'id' => 'DIS-2025-00035',
'party_one_name' => 'John Smith',
'party_one_role' => 'Sponsor',
'party_one_avatar' => 'https://images.unsplash.com/photo-1507003211169-0a1dd7228f2d?auto=format&fit=crop&q=80&w=100',
'party_two_name' => 'Maria Dela Cruz',
'party_two_role' => 'Worker',
'party_two_avatar' => 'https://images.unsplash.com/photo-1544005313-94ddf0286df2?auto=format&fit=crop&q=80&w=100',
'type' => 'Behavior Issue',
'reason' => 'Worker behavior not acceptable',
'priority' => 'Medium',
'status' => 'Under Review',
'assigned_to' => 'Ahmed Admin',
'created_at' => '2025-06-10 21:45:00',
'updated_at' => '2025-06-10 21:45:00'
],
[
'id' => 'DIS-2025-00034',
'party_one_name' => 'Fatima Ali',
'party_one_role' => 'Worker',
'party_one_avatar' => 'https://images.unsplash.com/photo-1438761681033-6461ffad8d80?auto=format&fit=crop&q=80&w=100',
'party_two_name' => 'Ahmed Hassan',
'party_two_role' => 'Sponsor',
'party_two_avatar' => 'https://images.unsplash.com/photo-1500648767791-00dcc994a43e?auto=format&fit=crop&q=80&w=100',
'type' => 'Working Conditions',
'reason' => 'Different work than promised',
'priority' => 'Medium',
'status' => 'Waiting Response',
'assigned_to' => 'Sarah Admin',
'created_at' => '2025-06-10 16:30:00',
'updated_at' => '2025-06-10 16:30:00'
],
[
'id' => 'DIS-2025-00033',
'party_one_name' => 'Ramesh Kumar',
'party_one_role' => 'Worker',
'party_one_avatar' => 'https://images.unsplash.com/photo-1539571696357-5a69c17a67c6?auto=format&fit=crop&q=80&w=100',
'party_two_name' => 'Demo Sponsor',
'party_two_role' => 'Sponsor',
'party_two_avatar' => 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?auto=format&fit=crop&q=80&w=100',
'type' => 'Abuse/Harassment',
'reason' => 'Verbal abuse and threats',
'priority' => 'High',
'status' => 'Under Review',
'assigned_to' => 'Ahmed Admin',
'created_at' => '2025-06-09 14:15:00',
'updated_at' => '2025-06-09 14:15:00'
]
]);
} }
public function down(): void public function down(): void

View File

@ -34,8 +34,8 @@ public function up(): void
if ($worker->verified) { if ($worker->verified) {
DB::table('audit_logs')->insert([ DB::table('audit_logs')->insert([
'category' => 'verification', 'category' => 'verification',
'user' => 'System Verification Engine', 'user' => 'System Vetting Engine',
'action' => 'Verification documents and credentials verification approved for worker: ' . $worker->name, 'action' => 'Vetting documents and credentials verification approved for worker: ' . $worker->name,
'ip_address' => 'Auto-Trigger', 'ip_address' => 'Auto-Trigger',
'created_at' => $worker->created_at ?: now(), 'created_at' => $worker->created_at ?: now(),
'updated_at' => $worker->created_at ?: now(), 'updated_at' => $worker->created_at ?: now(),

View File

@ -1,32 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('workers', function (Blueprint $table) {
$table->string('country')->nullable()->after('nationality');
$table->string('city')->nullable()->after('country');
$table->string('area')->nullable()->after('city');
$table->string('preferred_location')->nullable()->after('area');
$table->string('live_in_out')->nullable()->after('preferred_location');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('workers', function (Blueprint $table) {
$table->dropColumn(['country', 'city', 'area', 'preferred_location', 'live_in_out']);
});
}
};

View File

@ -1,28 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('workers', function (Blueprint $table) {
$table->string('gender')->nullable()->after('age');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('workers', function (Blueprint $table) {
$table->dropColumn('gender');
});
}
};

View File

@ -1,40 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
Schema::create('report_reasons', function (Blueprint $table) {
$table->id();
$table->string('reason');
$table->string('status')->default('Active'); // Active, Inactive
$table->timestamps();
});
// Insert 10 default report reasons
$reasons = [
['reason' => 'Abusive language / Profanity', 'status' => 'Active', 'created_at' => now(), 'updated_at' => now()],
['reason' => 'Harassment or stalking', 'status' => 'Active', 'created_at' => now(), 'updated_at' => now()],
['reason' => 'Fraud, scam, or fake profile', 'status' => 'Active', 'created_at' => now(), 'updated_at' => now()],
['reason' => 'Off-platform payment request', 'status' => 'Active', 'created_at' => now(), 'updated_at' => now()],
['reason' => 'Spam or promotional messaging', 'status' => 'Active', 'created_at' => now(), 'updated_at' => now()],
['reason' => 'Inappropriate photos/avatar', 'status' => 'Active', 'created_at' => now(), 'updated_at' => now()],
['reason' => 'Unprofessional conduct', 'status' => 'Active', 'created_at' => now(), 'updated_at' => now()],
['reason' => 'Threats or safety concerns', 'status' => 'Active', 'created_at' => now(), 'updated_at' => now()],
['reason' => 'Hate speech or discrimination', 'status' => 'Active', 'created_at' => now(), 'updated_at' => now()],
['reason' => 'Impersonation or identity theft', 'status' => 'Active', 'created_at' => now(), 'updated_at' => now()],
];
DB::table('report_reasons')->insert($reasons);
}
public function down(): void
{
Schema::dropIfExists('report_reasons');
}
};

View File

@ -1,28 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
Schema::table('report_reasons', function (Blueprint $table) {
$table->string('type')->default('Both'); // Chat, Review, Both
});
// Update default reasons to match appropriate scopes
DB::table('report_reasons')->where('reason', 'Harassment or stalking')->update(['type' => 'Chat']);
DB::table('report_reasons')->where('reason', 'Off-platform payment request')->update(['type' => 'Chat']);
DB::table('report_reasons')->where('reason', 'Spam or promotional messaging')->update(['type' => 'Chat']);
}
public function down(): void
{
Schema::table('report_reasons', function (Blueprint $table) {
$table->dropColumn('type');
});
}
};

View File

@ -1,23 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('employer_profiles', function (Blueprint $table) {
$table->string('emirates_id_number')->nullable();
$table->date('emirates_id_expiry')->nullable();
});
}
public function down(): void
{
Schema::table('employer_profiles', function (Blueprint $table) {
$table->dropColumn(['emirates_id_number', 'emirates_id_expiry']);
});
}
};

View File

@ -1,36 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('workers', function (Blueprint $table) {
if (!Schema::hasColumn('workers', 'in_country')) {
$table->boolean('in_country')->default(true)->after('nationality');
}
if (!Schema::hasColumn('workers', 'visa_status')) {
$table->string('visa_status')->nullable()->after('in_country');
}
if (!Schema::hasColumn('workers', 'preferred_job_type')) {
$table->string('preferred_job_type')->nullable()->after('live_in_out');
}
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('workers', function (Blueprint $table) {
$table->dropColumn(['in_country', 'visa_status', 'preferred_job_type']);
});
}
};

View File

@ -1,30 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::table('sponsors', function (Blueprint $table) {
if (!Schema::hasColumn('sponsors', 'api_token')) {
$table->string('api_token', 100)->nullable()->unique()->after('status');
}
if (!Schema::hasColumn('sponsors', 'license_file')) {
$table->string('license_file')->nullable()->after('api_token');
}
if (!Schema::hasColumn('sponsors', 'organization_name')) {
$table->string('organization_name')->nullable()->after('full_name');
}
});
}
public function down(): void
{
Schema::table('sponsors', function (Blueprint $table) {
$table->dropColumn(['api_token', 'license_file', 'organization_name']);
});
}
};

View File

@ -1,28 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('workers', function (Blueprint $table) {
$table->string('language', 255)->nullable()->change();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('workers', function (Blueprint $table) {
$table->string('language', 10)->nullable()->change();
});
}
};

View File

@ -1,28 +0,0 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('moderation_reports', function (Blueprint $table) {
$table->string('reported_user_id')->nullable()->after('type');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('moderation_reports', function (Blueprint $table) {
$table->dropColumn('reported_user_id');
});
}
};

View File

@ -1,99 +0,0 @@
<?php
namespace Database\Seeders;
use Illuminate\Database\Seeder;
use Illuminate\Support\Facades\DB;
use App\Models\User;
use App\Models\Worker;
use App\Models\Conversation;
use App\Models\Message;
class ConversationSeeder extends Seeder
{
/**
* Run the database seeds.
*/
public function run(): void
{
$employer = User::where('role', 'employer')->first();
if (!$employer) {
return;
}
$workers = Worker::where('status', 'active')->take(3)->get();
if ($workers->isEmpty()) {
return;
}
// Conversation 1: with Worker 1 (John Doe)
$w1 = $workers->get(0);
if ($w1) {
$conv1 = Conversation::create([
'employer_id' => $employer->id,
'worker_id' => $w1->id,
'created_at' => now()->subHours(2),
'updated_at' => now()->subHours(2),
]);
Message::create([
'conversation_id' => $conv1->id,
'sender_type' => 'employer',
'sender_id' => $employer->id,
'text' => 'Hello John, I saw your profile and I am interested in hiring you as an electrician.',
'created_at' => now()->subMinutes(110),
]);
Message::create([
'conversation_id' => $conv1->id,
'sender_type' => 'worker',
'sender_id' => $w1->id,
'text' => 'Hello, thank you for reaching out! Yes, I am available.',
'created_at' => now()->subMinutes(100),
]);
Message::create([
'conversation_id' => $conv1->id,
'sender_type' => 'employer',
'sender_id' => $employer->id,
'text' => 'Great, what is your salary expectation?',
'created_at' => now()->subMinutes(90),
]);
Message::create([
'conversation_id' => $conv1->id,
'sender_type' => 'worker',
'sender_id' => $w1->id,
'text' => 'My expectation is around 2500 AED per month.',
'created_at' => now()->subMinutes(80),
]);
}
// Conversation 2: with Worker 2 (Ali Hassan)
$w2 = $workers->get(1);
if ($w2) {
$conv2 = Conversation::create([
'employer_id' => $employer->id,
'worker_id' => $w2->id,
'created_at' => now()->subHour(),
'updated_at' => now()->subHour(),
]);
Message::create([
'conversation_id' => $conv2->id,
'sender_type' => 'employer',
'sender_id' => $employer->id,
'text' => 'Hi Ali, are you looking for job?',
'created_at' => now()->subMinutes(50),
]);
Message::create([
'conversation_id' => $conv2->id,
'sender_type' => 'worker',
'sender_id' => $w2->id,
'text' => 'Yes, I am available.',
'created_at' => now()->subMinutes(45),
]);
}
}
}

View File

@ -16,7 +16,6 @@ class DatabaseSeeder extends Seeder
public function run(): void public function run(): void
{ {
// Create Admin User // Create Admin User
if (!\Illuminate\Support\Facades\DB::table('users')->where('email', 'admin@example.com')->exists()) {
\Illuminate\Support\Facades\DB::table('users')->insert([ \Illuminate\Support\Facades\DB::table('users')->insert([
'name' => 'Admin User', 'name' => 'Admin User',
'email' => 'admin@example.com', 'email' => 'admin@example.com',
@ -25,16 +24,196 @@ public function run(): void
'created_at' => now(), 'created_at' => now(),
'updated_at' => now(), 'updated_at' => now(),
]); ]);
}
// Call Seeders // Call Seeders
$this->call([ $this->call([
WorkerCategorySeeder::class, WorkerCategorySeeder::class,
SkillSeeder::class, SkillSeeder::class,
WorkerSeeder::class,
EmployerProfileSeeder::class, EmployerProfileSeeder::class,
WorkerSeeder::class,
JobPostSeeder::class, JobPostSeeder::class,
ConversationSeeder::class, ]);
// Seed Conversations & Messages (assuming IDs exist)
$conversationId = \Illuminate\Support\Facades\DB::table('conversations')->insertGetId([
'employer_id' => 2, // From EmployerProfileSeeder
'worker_id' => 1, // From WorkerSeeder
'created_at' => now(),
'updated_at' => now(),
]);
\Illuminate\Support\Facades\DB::table('messages')->insert([
[
'conversation_id' => $conversationId,
'sender_type' => 'employer',
'sender_id' => 2,
'text' => 'Hello, are you available for an interview?',
'created_at' => now(),
],
'updated_at' => now(),
[
'conversation_id' => $conversationId,
'sender_type' => 'worker',
'sender_id' => 1,
'text' => 'Yes, I am available.',
'created_at' => now()->addMinutes(5),
'updated_at' => now()->addMinutes(5),
]
]);
// Seed Subscriptions
\Illuminate\Support\Facades\DB::table('subscriptions')->insert([
'user_id' => 2, // Employer created in EmployerProfileSeeder
'plan_id' => 'premium',
'amount_aed' => 499.00,
'starts_at' => now(),
'expires_at' => now()->addMonth(),
'status' => 'active',
'created_at' => now(),
'updated_at' => now(),
]);
// Seed Payments
\Illuminate\Support\Facades\DB::table('payments')->insert([
'user_id' => 2, // Employer
'amount' => 499.00,
'currency' => 'AED',
'description' => 'Premium Monthly Subscription',
'status' => 'success',
'created_at' => now(),
'updated_at' => now(),
]);
// Seed Sponsors (UAE Sponsor Marketplace Portal)
\Illuminate\Support\Facades\DB::table('sponsors')->insert([
[
'full_name' => 'Ahmed Malik (Al Barari Real Estate)',
'email' => 'hr@albarari.ae',
'mobile' => '+971 50 123 4567',
'password' => \Illuminate\Support\Facades\Hash::make('password'),
'country_code' => 'AE',
'subscription_status' => 'active',
'subscription_plan' => 'premium',
'subscription_start_date' => now()->subDays(15),
'subscription_end_date' => now()->addDays(350),
'payment_status' => 'paid',
'is_verified' => true,
'otp_verified_at' => now()->subDays(15),
'profile_image' => null,
'address' => 'Villa 14, Al Barari',
'city' => 'Dubai',
'nationality' => 'Emirati',
'status' => 'active',
'last_login_at' => now(),
'created_at' => now()->subDays(15),
'updated_at' => now(),
],
[
'full_name' => 'Sarah Johnson (Marina Cleaners)',
'email' => 'contact@marinaclean.com',
'mobile' => '+971 50 234 5678',
'password' => \Illuminate\Support\Facades\Hash::make('password'),
'country_code' => 'AE',
'subscription_status' => 'active',
'subscription_plan' => 'basic',
'subscription_start_date' => now()->subDays(10),
'subscription_end_date' => now()->addDays(355),
'payment_status' => 'paid',
'is_verified' => true,
'otp_verified_at' => now()->subDays(10),
'profile_image' => null,
'address' => 'Marina Heights, Floor 22',
'city' => 'Abu Dhabi',
'nationality' => 'British',
'status' => 'active',
'last_login_at' => now()->subHours(4),
'created_at' => now()->subDays(10),
'updated_at' => now(),
],
[
'full_name' => 'John Doe (Golden Hospitality)',
'email' => 'hiring@golden.hotel',
'mobile' => '+971 50 345 6789',
'password' => \Illuminate\Support\Facades\Hash::make('password'),
'country_code' => 'AE',
'subscription_status' => 'none',
'subscription_plan' => null,
'subscription_start_date' => null,
'subscription_end_date' => null,
'payment_status' => 'unpaid',
'is_verified' => false,
'otp_verified_at' => null,
'profile_image' => null,
'address' => 'Al Majaz 3, Corniche St',
'city' => 'Sharjah',
'nationality' => 'American',
'status' => 'inactive',
'last_login_at' => null,
'created_at' => now()->subDays(2),
'updated_at' => now(),
],
[
'full_name' => 'Mike Ross (Emirates Logistics)',
'email' => 'jobs@emirateslogistics.com',
'mobile' => '+971 50 456 7890',
'password' => \Illuminate\Support\Facades\Hash::make('password'),
'country_code' => 'AE',
'subscription_status' => 'active',
'subscription_plan' => 'premium',
'subscription_start_date' => now()->subDays(30),
'subscription_end_date' => now()->addDays(335),
'payment_status' => 'paid',
'is_verified' => true,
'otp_verified_at' => now()->subDays(30),
'profile_image' => null,
'address' => 'Logistics City, Zone 4',
'city' => 'Dubai',
'nationality' => 'Canadian',
'status' => 'active',
'last_login_at' => now()->subDays(1),
'created_at' => now()->subDays(30),
'updated_at' => now(),
],
[
'full_name' => 'Jane Foster (Dubai Mall Services)',
'email' => 'support@dubaimall.ae',
'mobile' => '+971 50 567 8901',
'password' => \Illuminate\Support\Facades\Hash::make('password'),
'country_code' => 'AE',
'subscription_status' => 'expired',
'subscription_plan' => 'vip',
'subscription_start_date' => now()->subDays(400),
'subscription_end_date' => now()->subDays(35),
'payment_status' => 'paid',
'is_verified' => true,
'otp_verified_at' => now()->subDays(400),
'profile_image' => null,
'address' => 'Downtown Dubai Mall Management',
'city' => 'Dubai',
'nationality' => 'Australian',
'status' => 'suspended',
'last_login_at' => now()->subDays(40),
'created_at' => now()->subDays(400),
'updated_at' => now(),
]
]);
// Seed Announcements
\Illuminate\Support\Facades\DB::table('announcements')->insert([
[
'title' => 'New Visa Regulations Update',
'body' => 'The Ministry of Human Resources and Emiratisation has announced updated guidelines for domestic worker sponsorship starting this month.',
'type' => 'info',
'created_at' => now(),
'updated_at' => now(),
],
[
'title' => 'Enhanced OCR Verification Active',
'body' => 'We have deployed upgraded OCR passport verification to ensure 100% legal compliance for all worker profiles on the platform.',
'type' => 'info',
'created_at' => now()->subDays(5),
'updated_at' => now()->subDays(5),
],
]); ]);
} }
} }

View File

@ -28,10 +28,6 @@ public function run(): void
'company_name' => 'Al Mansoor Household', 'company_name' => 'Al Mansoor Household',
'phone' => '+971 50 123 4567', 'phone' => '+971 50 123 4567',
'verification_status' => 'approved', 'verification_status' => 'approved',
'emirates_id_front' => '[DELETED_FOR_PDPL_COMPLIANCE]',
'emirates_id_back' => '[DELETED_FOR_PDPL_COMPLIANCE]',
'emirates_id_number' => '784-1990-1234567-8',
'emirates_id_expiry' => '2028-12-31',
'created_at' => now(), 'created_at' => now(),
'updated_at' => now(), 'updated_at' => now(),
]); ]);

View File

@ -18,14 +18,7 @@ public function run(): void
'email' => 'john.doe@example.com', 'email' => 'john.doe@example.com',
'phone' => '+971 50 111 2222', 'phone' => '+971 50 111 2222',
'nationality' => 'India', 'nationality' => 'India',
'country' => 'United Arab Emirates',
'city' => 'Dubai',
'area' => 'Dubai Marina',
'preferred_location' => 'Dubai Marina',
'live_in_out' => 'Live-in (Stay with family)',
'language' => 'English',
'age' => 30, 'age' => 30,
'gender' => 'Male',
'salary' => 2500.00, 'salary' => 2500.00,
'availability' => 'Immediate', 'availability' => 'Immediate',
'experience' => '5+ Years', 'experience' => '5+ Years',
@ -40,14 +33,7 @@ public function run(): void
'email' => 'ali.hassan@example.com', 'email' => 'ali.hassan@example.com',
'phone' => '+971 50 333 4444', 'phone' => '+971 50 333 4444',
'nationality' => 'Pakistan', 'nationality' => 'Pakistan',
'country' => 'United Arab Emirates',
'city' => 'Dubai',
'area' => 'Al Barsha',
'preferred_location' => 'Al Barsha',
'live_in_out' => 'Live-out (External housing)',
'language' => 'English',
'age' => 28, 'age' => 28,
'gender' => 'Male',
'salary' => 2000.00, 'salary' => 2000.00,
'availability' => '1 Month Notice', 'availability' => '1 Month Notice',
'experience' => '3 Years', 'experience' => '3 Years',
@ -62,14 +48,7 @@ public function run(): void
'email' => 'maria.santos@example.com', 'email' => 'maria.santos@example.com',
'phone' => '+971 50 555 6666', 'phone' => '+971 50 555 6666',
'nationality' => 'Philippines', 'nationality' => 'Philippines',
'country' => 'United Arab Emirates',
'city' => 'Abu Dhabi',
'area' => 'Yas Island',
'preferred_location' => 'Yas Island',
'live_in_out' => 'Live-in (Stay with family)',
'language' => 'Tagalog',
'age' => 32, 'age' => 32,
'gender' => 'Female',
'salary' => 1800.00, 'salary' => 1800.00,
'availability' => 'Immediate', 'availability' => 'Immediate',
'experience' => '5+ Years', 'experience' => '5+ Years',
@ -84,14 +63,7 @@ public function run(): void
'email' => 'lakshmi.sharma@example.com', 'email' => 'lakshmi.sharma@example.com',
'phone' => '+971 50 777 8888', 'phone' => '+971 50 777 8888',
'nationality' => 'India', 'nationality' => 'India',
'country' => 'United Arab Emirates',
'city' => 'Abu Dhabi',
'area' => 'Khalifa City',
'preferred_location' => 'Khalifa City',
'live_in_out' => 'Live-out (External housing)',
'language' => 'Hindi',
'age' => 38, 'age' => 38,
'gender' => 'Female',
'salary' => 1600.00, 'salary' => 1600.00,
'availability' => '2 Weeks', 'availability' => '2 Weeks',
'experience' => '3-5 Years', 'experience' => '3-5 Years',
@ -106,14 +78,7 @@ public function run(): void
'email' => 'siti.aminah@example.com', 'email' => 'siti.aminah@example.com',
'phone' => '+971 50 999 0000', 'phone' => '+971 50 999 0000',
'nationality' => 'Indonesia', 'nationality' => 'Indonesia',
'country' => 'United Arab Emirates',
'city' => 'Sharjah',
'area' => 'Al Nahda',
'preferred_location' => 'Al Nahda',
'live_in_out' => 'Live-in (Stay with family)',
'language' => 'Arabic',
'age' => 29, 'age' => 29,
'gender' => 'Female',
'salary' => 1400.00, 'salary' => 1400.00,
'availability' => 'Immediate', 'availability' => 'Immediate',
'experience' => '3-5 Years', 'experience' => '3-5 Years',
@ -128,14 +93,7 @@ public function run(): void
'email' => 'grace.osei@example.com', 'email' => 'grace.osei@example.com',
'phone' => '+971 50 222 3333', 'phone' => '+971 50 222 3333',
'nationality' => 'Ghana', 'nationality' => 'Ghana',
'country' => 'Saudi Arabia',
'city' => 'Riyadh',
'area' => 'Al Olaya',
'preferred_location' => 'Al Olaya',
'live_in_out' => 'Live-out (External housing)',
'language' => 'English',
'age' => 26, 'age' => 26,
'gender' => 'Female',
'salary' => 1500.00, 'salary' => 1500.00,
'availability' => '1 Month', 'availability' => '1 Month',
'experience' => '1-2 Years', 'experience' => '1-2 Years',
@ -150,14 +108,7 @@ public function run(): void
'email' => 'anoma.perera@example.com', 'email' => 'anoma.perera@example.com',
'phone' => '+971 50 444 5555', 'phone' => '+971 50 444 5555',
'nationality' => 'Sri Lanka', 'nationality' => 'Sri Lanka',
'country' => 'Saudi Arabia',
'city' => 'Jeddah',
'area' => 'Al Hamra',
'preferred_location' => 'Al Hamra',
'live_in_out' => 'Live-in (Stay with family)',
'language' => 'English',
'age' => 41, 'age' => 41,
'gender' => 'Female',
'salary' => 2200.00, 'salary' => 2200.00,
'availability' => 'Immediate', 'availability' => 'Immediate',
'experience' => '5+ Years', 'experience' => '5+ Years',
@ -172,14 +123,7 @@ public function run(): void
'email' => 'ramesh.thapa@example.com', 'email' => 'ramesh.thapa@example.com',
'phone' => '+971 50 666 7777', 'phone' => '+971 50 666 7777',
'nationality' => 'Nepal', 'nationality' => 'Nepal',
'country' => 'Saudi Arabia',
'city' => 'Riyadh',
'area' => 'Al Yasmin',
'preferred_location' => 'Al Yasmin',
'live_in_out' => 'Live-out (External housing)',
'language' => 'English',
'age' => 36, 'age' => 36,
'gender' => 'Male',
'salary' => 2500.00, 'salary' => 2500.00,
'availability' => '2 Weeks', 'availability' => '2 Weeks',
'experience' => '5+ Years', 'experience' => '5+ Years',
@ -191,61 +135,8 @@ public function run(): void
], ],
]; ];
// Dynamic Category Resolution
$categoryMapping = [
1 => 'Electrician',
2 => 'Mason',
3 => 'Plumber',
4 => 'Cleaner',
5 => 'Site Supervisor',
6 => 'Driver',
7 => 'General Helper',
8 => 'Childcare',
9 => 'Housekeeping',
10 => 'Cooking',
11 => 'Elderly Care',
];
$categoryIds = [];
foreach ($categoryMapping as $oldId => $name) {
$cat = \Illuminate\Support\Facades\DB::table('worker_categories')->where('name', $name)->first();
if (!$cat) {
$catId = \Illuminate\Support\Facades\DB::table('worker_categories')->insertGetId([
'name' => $name,
'created_at' => now(),
'updated_at' => now(),
]);
} else {
$catId = $cat->id;
}
$categoryIds[$oldId] = $catId;
}
// Dynamic Skill Resolution
$skillIds = \Illuminate\Support\Facades\DB::table('skills')->pluck('id')->toArray();
if (empty($skillIds)) {
foreach (['cooking', 'driving', 'childcare', 'cleaning', 'elderly care', 'gardening'] as $skillName) {
$skillIds[] = \Illuminate\Support\Facades\DB::table('skills')->insertGetId([
'name' => $skillName,
'created_at' => now(),
'updated_at' => now(),
]);
}
}
$sId1 = $skillIds[0] ?? 1;
$sId2 = $skillIds[1] ?? 2;
foreach ($workers as $workerData) { foreach ($workers as $workerData) {
$resolvedCategoryId = $categoryIds[$workerData['category_id']] ?? $workerData['category_id'];
// Exclude email duplicates to avoid unique constraint violations
if (\Illuminate\Support\Facades\DB::table('workers')->where('email', $workerData['email'])->exists()) {
continue;
}
$workerId = \Illuminate\Support\Facades\DB::table('workers')->insertGetId(array_merge($workerData, [ $workerId = \Illuminate\Support\Facades\DB::table('workers')->insertGetId(array_merge($workerData, [
'category_id' => $resolvedCategoryId,
'created_at' => now(), 'created_at' => now(),
'updated_at' => now(), 'updated_at' => now(),
])); ]));
@ -274,17 +165,17 @@ public function run(): void
] ]
]); ]);
// Seed Skills using resolved IDs // Seed Skills (assuming skill IDs 1 and 2 exist)
\Illuminate\Support\Facades\DB::table('worker_skills')->insert([ \Illuminate\Support\Facades\DB::table('worker_skills')->insert([
[ [
'worker_id' => $workerId, 'worker_id' => $workerId,
'skill_id' => $sId1, 'skill_id' => 1,
'created_at' => now(), 'created_at' => now(),
'updated_at' => now(), 'updated_at' => now(),
], ],
[ [
'worker_id' => $workerId, 'worker_id' => $workerId,
'skill_id' => $sId2, 'skill_id' => 2,
'created_at' => now(), 'created_at' => now(),
'updated_at' => now(), 'updated_at' => now(),
] ]

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 417 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 417 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 417 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 417 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 417 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 417 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 529 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 529 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 417 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 417 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 417 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 417 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 262 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 262 KiB

View File

@ -17,8 +17,7 @@ import {
Scale, Scale,
BellRing, BellRing,
BarChart3, BarChart3,
History, History
List
} from 'lucide-react'; } from 'lucide-react';
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet'; import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
@ -29,19 +28,18 @@ export default function AdminLayout({ children, title = 'Dashboard' }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const navItems = [ const navItems = [
{ name: 'Dashboard', href: '/admin/dashboard', icon: LayoutDashboard }, { name: 'Dashboard Overview', 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: 'Sponsor Vetting Dossiers', href: '/admin/employers', icon: Briefcase },
{ 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: 'Help & Support', href: '/admin/disputes', icon: Scale }, { name: 'Disputes System', href: '/admin/disputes', icon: Scale },
{ name: 'Payments', href: '/admin/payments', icon: BadgeDollarSign }, { name: 'Payments & Refunds', href: '/admin/payments', icon: BadgeDollarSign },
{ name: 'Campaigns & Alerts', href: '/admin/notifications', icon: BellRing }, { name: 'Campaigns & Alerts', href: '/admin/notifications', icon: BellRing },
{ name: 'Reports & Analytics', href: '/admin/analytics', icon: BarChart3 }, { name: 'Reports & Analytics', href: '/admin/analytics', icon: BarChart3 },
{ name: 'System Audit Logs', href: '/admin/audit-logs', icon: History }, { name: 'System Audit Logs', href: '/admin/audit-logs', icon: History },
{ name: 'Announcements', href: '/admin/announcements', icon: Megaphone }, { name: 'Announcements', href: '/admin/announcements', icon: Megaphone },
{ name: 'Skills', href: '/admin/master-data/skills', icon: Settings }, { name: 'Master Categories', href: '/admin/master-data/categories', icon: Settings },
{ name: 'Reason Master', href: '/admin/master-data/reasons', icon: List },
]; ];
const getInitials = (name) => { const getInitials = (name) => {

View File

@ -55,11 +55,11 @@ export default function EmployerLayout({ children, title, fullPage = false }) {
name: 'Guest', name: 'Guest',
company_name: '', company_name: '',
email: '', email: '',
subscription_status: 'active', subscription_status: 'inactive',
subscription_expires_at: '', subscription_expires_at: '',
}; };
const isSubActive = true; const isSubActive = user.subscription_status === 'active';
const navItems = [ const navItems = [
{ name: 'Dashboard', translationKey: 'dashboard', href: '/employer/dashboard', icon: LayoutDashboard }, { name: 'Dashboard', translationKey: 'dashboard', href: '/employer/dashboard', icon: LayoutDashboard },

View File

@ -61,7 +61,7 @@ export default function AnalyticsHub({
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 border-b border-slate-200 pb-5"> <div className="flex flex-col md:flex-row md:items-center justify-between gap-4 border-b border-slate-200 pb-5">
<div> <div>
<h1 className="text-xl font-black text-slate-800 uppercase tracking-tight">Platform Reports</h1> <h1 className="text-xl font-black text-slate-800 uppercase tracking-tight">Platform Reports</h1>
<p className="text-xs text-slate-500 mt-0.5 font-medium">Summary of workers, employers, disputes, and payments.</p> <p className="text-xs text-slate-500 mt-0.5 font-medium">Summary of workers, sponsors, disputes, and payments.</p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -118,11 +118,11 @@ export default function AnalyticsHub({
<Building2 className="w-5 h-5" /> <Building2 className="w-5 h-5" />
</div> </div>
<span className="text-[10px] bg-blue-50 text-blue-700 font-extrabold uppercase px-2 py-0.5 rounded tracking-wide"> <span className="text-[10px] bg-blue-50 text-blue-700 font-extrabold uppercase px-2 py-0.5 rounded tracking-wide">
Employers Sponsors
</span> </span>
</div> </div>
<div> <div>
<span className="text-[10px] text-slate-400 font-bold uppercase tracking-wider block">Total Employers</span> <span className="text-[10px] text-slate-400 font-bold uppercase tracking-wider block">Total Sponsors</span>
<span className="text-3xl font-black text-slate-800 block mt-1 tracking-tight">{sponsor_stats.total}</span> <span className="text-3xl font-black text-slate-800 block mt-1 tracking-tight">{sponsor_stats.total}</span>
</div> </div>
<div className="pt-2 border-t border-slate-100 grid grid-cols-2 gap-2 text-[10px] font-bold text-slate-500"> <div className="pt-2 border-t border-slate-100 grid grid-cols-2 gap-2 text-[10px] font-bold text-slate-500">

View File

@ -28,23 +28,18 @@ export default function Dashboard({ stats, recent_verifications, recent_subscrip
const [activeTrendTab, setActiveTrendTab] = useState('All'); const [activeTrendTab, setActiveTrendTab] = useState('All');
const [selectedSlice, setSelectedSlice] = useState(null); const [selectedSlice, setSelectedSlice] = useState(null);
// Dynamic trend monthly data for custom SVG Line chart // Mock trend monthly data for custom SVG Line chart
const trendData = stats?.trend_data || [ const trendData = [
{ month: 'Dec', basic: 0, premium: 0, vip: 0 }, { month: 'Dec', basic: 110, premium: 60, vip: 15 },
{ month: 'Jan', basic: 0, premium: 0, vip: 0 }, { month: 'Jan', basic: 130, premium: 70, vip: 18 },
{ month: 'Feb', basic: 0, premium: 0, vip: 0 }, { month: 'Feb', basic: 145, premium: 82, vip: 22 },
{ month: 'Mar', basic: 0, premium: 0, vip: 0 }, { month: 'Mar', basic: 160, premium: 88, vip: 24 },
{ month: 'Apr', basic: 0, premium: 0, vip: 0 }, { month: 'Apr', basic: 172, premium: 92, vip: 28 },
{ month: 'May', basic: 0, premium: 0, vip: 0 } { month: 'May', basic: 184, premium: 98, vip: 30 }
]; ];
const maxVal = Math.max(
10,
...trendData.map(d => Math.max(d.basic || 0, d.premium || 0, d.vip || 0))
);
// Compute lines points for SVG Spline // Compute lines points for SVG Spline
const getCoordinatesForLine = (key, height, width, max) => { const getCoordinatesForLine = (key, height, width, maxVal) => {
const points = []; const points = [];
const paddingLeft = 35; const paddingLeft = 35;
const paddingRight = 15; const paddingRight = 15;
@ -55,17 +50,17 @@ export default function Dashboard({ stats, recent_verifications, recent_subscrip
const graphWidth = width - paddingLeft - paddingRight; const graphWidth = width - paddingLeft - paddingRight;
trendData.forEach((d, idx) => { trendData.forEach((d, idx) => {
const val = d[key] || 0; const val = d[key];
const x = paddingLeft + (idx / (trendData.length - 1)) * graphWidth; const x = paddingLeft + (idx / (trendData.length - 1)) * graphWidth;
const y = paddingTop + graphHeight - (val / max) * graphHeight; const y = paddingTop + graphHeight - (val / maxVal) * graphHeight;
points.push(`${x},${y}`); points.push(`${x},${y}`);
}); });
return points.join(' '); return points.join(' ');
}; };
return ( return (
<AdminLayout title="Dashboard"> <AdminLayout title="Dashboard Overview">
<Head title="Dashboard" /> <Head title="Admin Dashboard" />
{/* Welcome banner */} {/* Welcome banner */}
<div className="bg-slate-900 rounded-3xl p-6 text-white mb-8 relative overflow-hidden font-sans shadow-lg shadow-slate-900/10"> <div className="bg-slate-900 rounded-3xl p-6 text-white mb-8 relative overflow-hidden font-sans shadow-lg shadow-slate-900/10">
@ -78,7 +73,7 @@ export default function Dashboard({ stats, recent_verifications, recent_subscrip
<span>System Status: Online</span> <span>System Status: Online</span>
</div> </div>
<h2 className="text-2xl font-black tracking-tight mt-2">Welcome back, Administrator</h2> <h2 className="text-2xl font-black tracking-tight mt-2">Welcome back, Administrator</h2>
<p className="text-sm text-slate-400 font-medium">UAE domestic workers platform is operating optimally. {stats?.verifications_today || 0} automated verifications completed today.</p> <p className="text-sm text-slate-400 font-medium">UAE domestic workers platform is operating optimally. 12 automated verifications completed today.</p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Link <Link
@ -104,12 +99,12 @@ export default function Dashboard({ stats, recent_verifications, recent_subscrip
</div> </div>
<div className="mt-4"> <div className="mt-4">
<h3 className="text-3xl font-black text-slate-900 tracking-tight"> <h3 className="text-3xl font-black text-slate-900 tracking-tight">
{stats?.total_workers?.toLocaleString() || 0} {stats?.total_workers?.toLocaleString() || 1420}
</h3> </h3>
<div className="flex items-center space-x-2 mt-2 text-xs font-bold"> <div className="flex items-center space-x-2 mt-2 text-xs font-bold">
<span className="text-emerald-600">{stats?.active_workers || 0} Active</span> <span className="text-emerald-600">{stats?.active_workers || 980} Active</span>
<span className="text-slate-300"></span> <span className="text-slate-300"></span>
<span className="text-slate-400">{stats?.inactive_workers || 0} Inactive</span> <span className="text-slate-400">{stats?.inactive_workers || 440} Inactive</span>
</div> </div>
</div> </div>
</div> </div>
@ -117,18 +112,18 @@ export default function Dashboard({ stats, recent_verifications, recent_subscrip
{/* Verification Stats */} {/* Verification Stats */}
<div className="bg-white rounded-2xl p-6 border border-slate-100 shadow-sm flex flex-col justify-between hover:shadow-md transition-shadow"> <div className="bg-white rounded-2xl p-6 border border-slate-100 shadow-sm flex flex-col justify-between hover:shadow-md transition-shadow">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="text-xs font-black text-slate-400 uppercase tracking-wider">Verification & OCR Rate</span> <span className="text-xs font-black text-slate-400 uppercase tracking-wider">Vetting & OCR Rate</span>
<div className="w-10 h-10 bg-emerald-50 rounded-lg flex items-center justify-center shadow-inner"> <div className="w-10 h-10 bg-emerald-50 rounded-lg flex items-center justify-center shadow-inner">
<ShieldCheck className="w-5 h-5 text-emerald-600" /> <ShieldCheck className="w-5 h-5 text-emerald-600" />
</div> </div>
</div> </div>
<div className="mt-4"> <div className="mt-4">
<h3 className="text-3xl font-black text-slate-900 tracking-tight"> <h3 className="text-3xl font-black text-slate-900 tracking-tight">
{stats?.verification_rate || 0}% {stats?.verification_rate || 88.5}%
</h3> </h3>
<p className="text-xs text-slate-500 font-medium mt-2 flex items-center"> <p className="text-xs text-slate-500 font-medium mt-2 flex items-center">
<CheckCircle className="w-3.5 h-3.5 text-emerald-500 mr-1 inline" /> <CheckCircle className="w-3.5 h-3.5 text-emerald-500 mr-1 inline" />
<span>{stats?.verified_workers_count?.toLocaleString() || 0} Profiles Auto-OCR Verified</span> <span>1,256 Profiles Auto-OCR Verified</span>
</p> </p>
</div> </div>
</div> </div>
@ -143,11 +138,11 @@ export default function Dashboard({ stats, recent_verifications, recent_subscrip
</div> </div>
<div className="mt-4"> <div className="mt-4">
<h3 className="text-3xl font-black text-slate-900 tracking-tight"> <h3 className="text-3xl font-black text-slate-900 tracking-tight">
{stats?.hiring_conversion_rate || 0}% {stats?.hiring_conversion_rate || 14.8}%
</h3> </h3>
<div className="flex items-center space-x-2 mt-2 text-xs font-bold text-blue-600"> <div className="flex items-center space-x-2 mt-2 text-xs font-bold text-blue-600">
<MessageSquare className="w-3.5 h-3.5" /> <MessageSquare className="w-3.5 h-3.5" />
<span>{stats?.chat_to_hire_rate || 0}% Chat-to-Hire</span> <span>{stats?.chat_to_hire_rate || 12.6}% Chat-to-Hire</span>
</div> </div>
</div> </div>
</div> </div>
@ -162,11 +157,11 @@ export default function Dashboard({ stats, recent_verifications, recent_subscrip
</div> </div>
<div className="mt-4"> <div className="mt-4">
<h3 className="text-3xl font-black text-slate-900 tracking-tight"> <h3 className="text-3xl font-black text-slate-900 tracking-tight">
AED {stats?.revenue_this_month_aed?.toLocaleString() || 0} AED {stats?.revenue_this_month_aed?.toLocaleString() || '48,500'}
</h3> </h3>
<p className="text-xs text-emerald-600 font-bold mt-2 flex items-center"> <p className="text-xs text-emerald-600 font-bold mt-2 flex items-center">
<ArrowUpRight className="w-3.5 h-3.5 mr-0.5" /> <ArrowUpRight className="w-3.5 h-3.5 mr-0.5" />
<span>+{stats?.new_employers_this_week || 0} Employers Added This Week</span> <span>+{stats?.new_employers_this_week || 28} Sponsors Added This Week</span>
</p> </p>
</div> </div>
</div> </div>
@ -204,7 +199,9 @@ export default function Dashboard({ stats, recent_verifications, recent_subscrip
<line x1="35" y1="20" x2="435" y2="20" stroke="#f1f5f9" strokeWidth="1" /> <line x1="35" y1="20" x2="435" y2="20" stroke="#f1f5f9" strokeWidth="1" />
<line x1="35" y1="75" x2="435" y2="75" stroke="#f1f5f9" strokeWidth="1" /> <line x1="35" y1="75" x2="435" y2="75" stroke="#f1f5f9" strokeWidth="1" />
<line x1="35" y1="130" x2="435" y2="130" stroke="#f1f5f9" strokeWidth="1" /> <line x1="35" y1="130" x2="435" y2="130" stroke="#f1f5f9" strokeWidth="1" />
<line x1="35" y1="180" x2="435" y2="180" stroke="#cbd5e1" strokeWidth="1" /> {/* Line 1: Basic */} <line x1="35" y1="180" x2="435" y2="180" stroke="#cbd5e1" strokeWidth="1" />
{/* Line 1: Basic */}
{(activeTrendTab === 'All') && ( {(activeTrendTab === 'All') && (
<polyline <polyline
fill="none" fill="none"
@ -212,7 +209,7 @@ export default function Dashboard({ stats, recent_verifications, recent_subscrip
strokeWidth="3.5" strokeWidth="3.5"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
points={getCoordinatesForLine('basic', 200, 450, maxVal)} points={getCoordinatesForLine('basic', 200, 450, 200)}
className="transition-all duration-500 opacity-60" className="transition-all duration-500 opacity-60"
/> />
)} )}
@ -225,7 +222,7 @@ export default function Dashboard({ stats, recent_verifications, recent_subscrip
strokeWidth="4" strokeWidth="4"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
points={getCoordinatesForLine('premium', 200, 450, maxVal)} points={getCoordinatesForLine('premium', 200, 450, 200)}
className="transition-all duration-500" className="transition-all duration-500"
/> />
)} )}
@ -238,7 +235,7 @@ export default function Dashboard({ stats, recent_verifications, recent_subscrip
strokeWidth="4.5" strokeWidth="4.5"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
points={getCoordinatesForLine('vip', 200, 450, maxVal)} points={getCoordinatesForLine('vip', 200, 450, 200)}
className="transition-all duration-500" className="transition-all duration-500"
/> />
)} )}
@ -254,9 +251,9 @@ export default function Dashboard({ stats, recent_verifications, recent_subscrip
})} })}
{/* Y-Axis Labels */} {/* Y-Axis Labels */}
<text x="25" y="25" textAnchor="end" className="text-[10px] font-black fill-slate-300">{maxVal}</text> <text x="25" y="25" textAnchor="end" className="text-[10px] font-black fill-slate-300">200</text>
<text x="25" y="80" textAnchor="end" className="text-[10px] font-black fill-slate-300">{Math.round(maxVal / 2)}</text> <text x="25" y="80" textAnchor="end" className="text-[10px] font-black fill-slate-300">100</text>
<text x="25" y="135" textAnchor="end" className="text-[10px] font-black fill-slate-300">{Math.round(maxVal / 4)}</text> <text x="25" y="135" textAnchor="end" className="text-[10px] font-black fill-slate-300">50</text>
<text x="25" y="185" textAnchor="end" className="text-[10px] font-black fill-slate-300">0</text> <text x="25" y="185" textAnchor="end" className="text-[10px] font-black fill-slate-300">0</text>
</svg> </svg>
@ -264,15 +261,15 @@ export default function Dashboard({ stats, recent_verifications, recent_subscrip
<div className="flex items-center justify-center space-x-6 mt-4"> <div className="flex items-center justify-center space-x-6 mt-4">
<div className="flex items-center space-x-2 text-xs font-bold text-slate-600"> <div className="flex items-center space-x-2 text-xs font-bold text-slate-600">
<div className="w-3 h-1 bg-slate-400 rounded" /> <div className="w-3 h-1 bg-slate-400 rounded" />
<span>Basic Search ({trendData[trendData.length - 1]?.basic || 0})</span> <span>Basic Search ({trendData[5].basic})</span>
</div> </div>
<div className="flex items-center space-x-2 text-xs font-bold text-slate-600"> <div className="flex items-center space-x-2 text-xs font-bold text-slate-600">
<div className="w-3 h-1 bg-blue-500 rounded" /> <div className="w-3 h-1 bg-blue-500 rounded" />
<span>Premium Pass ({trendData[trendData.length - 1]?.premium || 0})</span> <span>Premium Pass ({trendData[5].premium})</span>
</div> </div>
<div className="flex items-center space-x-2 text-xs font-bold text-slate-600"> <div className="flex items-center space-x-2 text-xs font-bold text-slate-600">
<div className="w-3 h-1 bg-emerald-500 rounded" /> <div className="w-3 h-1 bg-emerald-500 rounded" />
<span>VIP Concierge ({trendData[trendData.length - 1]?.vip || 0})</span> <span>VIP Concierge ({trendData[5].vip})</span>
</div> </div>
</div> </div>
</div> </div>
@ -288,72 +285,46 @@ export default function Dashboard({ stats, recent_verifications, recent_subscrip
<p className="text-xs text-slate-500 font-medium uppercase tracking-widest mt-1">Real-time availability of candidates pool</p> <p className="text-xs text-slate-500 font-medium uppercase tracking-widest mt-1">Real-time availability of candidates pool</p>
</div> </div>
{(() => {
const activeVal = stats?.worker_availability?.['Active'] || 0;
const hiddenVal = stats?.worker_availability?.['Hidden'] || 0;
const hiredVal = stats?.worker_availability?.['Hired'] || 0;
const totalVal = activeVal + hiddenVal + hiredVal;
const activePct = totalVal > 0 ? Math.round((activeVal / totalVal) * 100) : 0;
const hiddenPct = totalVal > 0 ? Math.round((hiddenVal / totalVal) * 100) : 0;
const hiredPct = totalVal > 0 ? Math.round((hiredVal / totalVal) * 100) : 0;
return (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 items-center my-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4 items-center my-4">
{/* Custom Donut Chart */} {/* Custom Donut Chart */}
<div className="flex justify-center relative"> <div className="flex justify-center relative">
<svg className="w-40 h-40" viewBox="0 0 100 100"> <svg className="w-40 h-40" viewBox="0 0 100 100">
{totalVal > 0 ? ( {/* Segment 1: Available Now (640 / 1420 = 45%) -> Stroke-dasharray: 45, 55 */}
<g className="transform -rotate-90 origin-center">
{/* Segment 1: Active */}
<circle <circle
cx="50" cy="50" r="40" cx="50" cy="50" r="40"
fill="transparent" fill="transparent"
stroke="#10b981" stroke="#10b981"
strokeWidth="12" strokeWidth="12"
pathLength="100" strokeDasharray="45 55"
strokeDasharray={`${activePct} ${100 - activePct}`} strokeDashoffset="25"
strokeDashoffset="0" onMouseEnter={() => setSelectedSlice('Available')}
onMouseEnter={() => setSelectedSlice('Active')}
onMouseLeave={() => setSelectedSlice(null)} onMouseLeave={() => setSelectedSlice(null)}
className="cursor-pointer transition-all duration-300 hover:stroke-[14]" className="cursor-pointer transition-all duration-300 hover:stroke-[14]"
/> />
{/* Segment 2: Hidden */} {/* Segment 2: Contracted (480 / 1420 = 34%) -> Stroke-dasharray: 34, 66 */}
<circle <circle
cx="50" cy="50" r="40" cx="50" cy="50" r="40"
fill="transparent" fill="transparent"
stroke="#94a3b8" stroke="#94a3b8"
strokeWidth="12" strokeWidth="12"
pathLength="100" strokeDasharray="34 66"
strokeDasharray={`${hiddenPct} ${100 - hiddenPct}`} strokeDashoffset="-20"
strokeDashoffset={`-${activePct}`} onMouseEnter={() => setSelectedSlice('Contracted')}
onMouseEnter={() => setSelectedSlice('Hidden')}
onMouseLeave={() => setSelectedSlice(null)} onMouseLeave={() => setSelectedSlice(null)}
className="cursor-pointer transition-all duration-300 hover:stroke-[14]" className="cursor-pointer transition-all duration-300 hover:stroke-[14]"
/> />
{/* Segment 3: Hired */} {/* Segment 3: In Interview (300 / 1420 = 21%) -> Stroke-dasharray: 21, 79 */}
<circle <circle
cx="50" cy="50" r="40" cx="50" cy="50" r="40"
fill="transparent" fill="transparent"
stroke="#3b82f6" stroke="#3b82f6"
strokeWidth="12" strokeWidth="12"
pathLength="100" strokeDasharray="21 79"
strokeDasharray={`${hiredPct} ${100 - hiredPct}`} strokeDashoffset="-54"
strokeDashoffset={`-${activePct + hiddenPct}`} onMouseEnter={() => setSelectedSlice('Interviewing')}
onMouseEnter={() => setSelectedSlice('Hired')}
onMouseLeave={() => setSelectedSlice(null)} onMouseLeave={() => setSelectedSlice(null)}
className="cursor-pointer transition-all duration-300 hover:stroke-[14]" className="cursor-pointer transition-all duration-300 hover:stroke-[14]"
/> />
</g>
) : (
/* Empty State Grey Circle */
<circle
cx="50" cy="50" r="40"
fill="transparent"
stroke="#e2e8f0"
strokeWidth="12"
/>
)}
{/* Inner Text */} {/* Inner Text */}
<circle cx="50" cy="50" r="28" fill="white" /> <circle cx="50" cy="50" r="28" fill="white" />
@ -361,7 +332,7 @@ export default function Dashboard({ stats, recent_verifications, recent_subscrip
{selectedSlice || 'TOTAL'} {selectedSlice || 'TOTAL'}
</text> </text>
<text x="50" y="60" textAnchor="middle" className="text-[8px] font-black fill-slate-400 tracking-wider"> <text x="50" y="60" textAnchor="middle" className="text-[8px] font-black fill-slate-400 tracking-wider">
{selectedSlice === 'Active' ? `${activeVal} Workers` : selectedSlice === 'Hidden' ? `${hiddenVal} Workers` : selectedSlice === 'Hired' ? `${hiredVal} Workers` : `${totalVal} Total`} {selectedSlice === 'Available' ? '640 Workers' : selectedSlice === 'Contracted' ? '480 Workers' : selectedSlice === 'Interviewing' ? '300 Workers' : '1,420 Total'}
</text> </text>
</svg> </svg>
</div> </div>
@ -371,28 +342,26 @@ export default function Dashboard({ stats, recent_verifications, recent_subscrip
<div className="p-3 bg-emerald-50/50 border border-emerald-100 rounded-xl flex items-center justify-between"> <div className="p-3 bg-emerald-50/50 border border-emerald-100 rounded-xl flex items-center justify-between">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<div className="w-2.5 h-2.5 bg-emerald-500 rounded-full" /> <div className="w-2.5 h-2.5 bg-emerald-500 rounded-full" />
<span className="text-xs font-bold text-slate-700">Active</span> <span className="text-xs font-bold text-slate-700">Available Now</span>
</div> </div>
<span className="text-xs font-black text-slate-900">{activeVal} ({activePct}%)</span> <span className="text-xs font-black text-slate-900">640 (45%)</span>
</div>
<div className="p-3 bg-slate-50 border border-slate-100 rounded-xl flex items-center justify-between">
<div className="flex items-center space-x-2">
<div className="w-2.5 h-2.5 bg-slate-400 rounded-full" />
<span className="text-xs font-bold text-slate-700">Hidden</span>
</div>
<span className="text-xs font-black text-slate-900">{hiddenVal} ({hiddenPct}%)</span>
</div> </div>
<div className="p-3 bg-blue-50/50 border border-blue-100 rounded-xl flex items-center justify-between"> <div className="p-3 bg-blue-50/50 border border-blue-100 rounded-xl flex items-center justify-between">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<div className="w-2.5 h-2.5 bg-blue-500 rounded-full" /> <div className="w-2.5 h-2.5 bg-blue-500 rounded-full" />
<span className="text-xs font-bold text-slate-700">Hired</span> <span className="text-xs font-bold text-slate-700">In Interview</span>
</div> </div>
<span className="text-xs font-black text-slate-900">{hiredVal} ({hiredPct}%)</span> <span className="text-xs font-black text-slate-900">300 (21%)</span>
</div>
<div className="p-3 bg-slate-50 border border-slate-100 rounded-xl flex items-center justify-between">
<div className="flex items-center space-x-2">
<div className="w-2.5 h-2.5 bg-slate-400 rounded-full" />
<span className="text-xs font-bold text-slate-700">Engaged</span>
</div>
<span className="text-xs font-black text-slate-900">480 (34%)</span>
</div> </div>
</div> </div>
</div> </div>
);
})()}
</div> </div>
</div> </div>
@ -403,30 +372,16 @@ export default function Dashboard({ stats, recent_verifications, recent_subscrip
<p className="text-xs text-slate-500 font-medium uppercase tracking-widest mt-1">Platform-wide worker placement workflow stats</p> <p className="text-xs text-slate-500 font-medium uppercase tracking-widest mt-1">Platform-wide worker placement workflow stats</p>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4"> <div className="grid grid-cols-1 md:grid-cols-5 gap-4">
{(() => { {[
const funnelData = stats?.funnel || { { stage: 'Profiles Browsed', val: '12,500', pct: '100%', bg: 'bg-slate-100 border-slate-200 text-slate-600' },
profiles_browsed: 0, { stage: 'Chats Initiated', val: '4,820', pct: '38.5%', bg: 'bg-teal-50 border-teal-100 text-teal-700' },
chats_initiated: 0, { stage: 'Interviews Held', val: '1,860', pct: '14.8%', bg: 'bg-blue-50 border-blue-100 text-blue-700' },
candidates_shortlisted: 0, { stage: 'Offers Sent', val: '920', pct: '7.3%', bg: 'bg-purple-50 border-purple-100 text-purple-700' },
workers_hired: 0 { stage: 'Workers Hired', val: '610', pct: '4.8%', bg: 'bg-emerald-50 border-emerald-100 text-emerald-700' }
}; ].map((step, i) => (
const browsed = funnelData.profiles_browsed || 0;
const chats = funnelData.chats_initiated || 0;
const shortlisted = funnelData.candidates_shortlisted || 0;
const hired = funnelData.workers_hired || 0;
const funnelSteps = [
{ stage: 'Profiles Browsed', val: browsed.toLocaleString(), pct: '100%', bg: 'bg-slate-100 border-slate-200 text-slate-600' },
{ stage: 'Chats Initiated', val: chats.toLocaleString(), pct: browsed > 0 ? `${((chats / browsed) * 100).toFixed(1)}%` : '0%', bg: 'bg-teal-50 border-teal-100 text-teal-700' },
{ stage: 'Candidates Shortlisted', val: shortlisted.toLocaleString(), pct: browsed > 0 ? `${((shortlisted / browsed) * 100).toFixed(1)}%` : '0%', bg: 'bg-blue-50 border-blue-100 text-blue-700' },
{ stage: 'Workers Hired', val: hired.toLocaleString(), pct: browsed > 0 ? `${((hired / browsed) * 100).toFixed(1)}%` : '0%', bg: 'bg-emerald-50 border-emerald-100 text-emerald-700' }
];
return funnelSteps.map((step, i) => (
<div key={i} className={`p-4 border rounded-2xl ${step.bg} flex flex-col justify-between relative`}> <div key={i} className={`p-4 border rounded-2xl ${step.bg} flex flex-col justify-between relative`}>
{i < 3 && ( {i < 4 && (
<div className="hidden md:block absolute -right-3 top-1/2 -translate-y-1/2 z-10 w-6 h-6 bg-white border border-slate-100 rounded-full flex items-center justify-center shadow-sm"> <div className="hidden md:block absolute -right-3 top-1/2 -translate-y-1/2 z-10 w-6 h-6 bg-white border border-slate-100 rounded-full flex items-center justify-center shadow-sm">
<ArrowRight className="w-3.5 h-3.5 text-slate-400" /> <ArrowRight className="w-3.5 h-3.5 text-slate-400" />
</div> </div>
@ -440,11 +395,116 @@ export default function Dashboard({ stats, recent_verifications, recent_subscrip
<div className="text-[10px] font-bold mt-0.5">Ratio: {step.pct}</div> <div className="text-[10px] font-bold mt-0.5">Ratio: {step.pct}</div>
</div> </div>
</div> </div>
)); ))}
})()}
</div> </div>
</div> </div>
{/* Recent Verifications Section */}
<div className="bg-white rounded-xl border border-gray-200 shadow-sm mb-8 overflow-hidden font-sans">
<div className="p-5 border-b border-gray-100 flex items-center justify-between bg-white">
<div>
<h2 className="text-base font-semibold text-gray-800 tracking-tight">Recent Verifications</h2>
<p className="text-xs text-gray-500 mt-0.5">Profiles automatically verified via document proof</p>
</div>
<Link
href="/admin/workers/verifications"
className="text-xs font-semibold text-[#0F6E56] hover:text-[#085041] flex items-center transition-colors"
>
View OCR Vetting queue <ArrowRight className="w-3.5 h-3.5 ml-1" />
</Link>
</div>
{recent_verifications && recent_verifications.length > 0 ? (
<Table>
<TableHeader>
<TableRow className="bg-slate-50/50 hover:bg-slate-50/50">
<TableHead className="font-semibold text-slate-600 text-xs h-10">Name</TableHead>
<TableHead className="font-semibold text-slate-600 text-xs h-10">Type</TableHead>
<TableHead className="font-semibold text-slate-600 text-xs h-10">Document</TableHead>
<TableHead className="font-semibold text-slate-600 text-xs h-10">Processed</TableHead>
<TableHead className="font-semibold text-slate-600 text-xs h-10 text-right pr-6">Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{recent_verifications.map((item) => (
<TableRow key={item.id} className="hover:bg-slate-50/80 transition-colors">
<TableCell className="font-medium text-gray-900 text-sm">{item.name}</TableCell>
<TableCell>
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${
item.type === 'Worker' ? 'bg-teal-50 text-[#0F6E56]' : 'bg-indigo-50 text-indigo-700'
}`}>
{item.type}
</span>
</TableCell>
<TableCell className="text-sm text-gray-600">{item.document_type}</TableCell>
<TableCell className="text-xs text-gray-500">{item.processed_at}</TableCell>
<TableCell className="text-right pr-6">
<span className="inline-flex items-center px-2.5 py-1 rounded-lg text-xs font-semibold bg-emerald-100 text-emerald-800">
<CheckCircle className="w-3 h-3 mr-1" />
{item.status || 'Verified'}
</span>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<div className="p-12 text-center flex flex-col items-center justify-center">
<CheckCircle className="w-12 h-12 text-emerald-500 mb-3" />
<h3 className="text-base font-semibold text-gray-800 tracking-tight">No recent activity</h3>
<p className="text-xs text-gray-500 mt-1">Automatic verification system is monitoring new registrations.</p>
</div>
)}
</div>
{/* Recent Subscriptions Section */}
<div className="bg-white rounded-xl border border-gray-200 shadow-sm overflow-hidden font-sans">
<div className="p-5 border-b border-gray-100 flex items-center justify-between bg-white">
<div>
<h2 className="text-base font-semibold text-gray-800 tracking-tight">Recent Subscriptions</h2>
<p className="text-xs text-gray-500 mt-0.5">Latest sponsor subscription activations and renewals</p>
</div>
<Link
href="/admin/payments"
className="text-xs font-semibold text-[#0F6E56] hover:text-[#085041] flex items-center transition-colors"
>
View all payments <ArrowRight className="w-3.5 h-3.5 ml-1" />
</Link>
</div>
{recent_subscriptions && recent_subscriptions.length > 0 ? (
<Table>
<TableHeader>
<TableRow className="bg-slate-50/50 hover:bg-slate-50/50">
<TableHead className="font-semibold text-slate-600 text-xs h-10">Sponsor</TableHead>
<TableHead className="font-semibold text-slate-600 text-xs h-10">Plan</TableHead>
<TableHead className="font-semibold text-slate-600 text-xs h-10">Amount</TableHead>
<TableHead className="font-semibold text-slate-600 text-xs h-10 text-right pr-6">Date</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{recent_subscriptions.map((item) => (
<TableRow key={item.id} className="hover:bg-slate-50/80 transition-colors">
<TableCell className="font-medium text-gray-900 text-sm">{item.employer_name}</TableCell>
<TableCell>
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-50 text-blue-700">
{item.plan_name}
</span>
</TableCell>
<TableCell className="text-sm font-medium text-gray-900">
AED {item.amount_aed}
</TableCell>
<TableCell className="text-xs text-gray-500 text-right pr-6">{item.subscribed_at}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<div className="p-12 text-center text-gray-500 text-xs font-medium">
No recent subscriptions found.
</div>
)}
</div>
</AdminLayout> </AdminLayout>
); );
} }

View File

@ -98,16 +98,16 @@ export default function DisputesHub({ tickets }) {
}); });
return ( return (
<AdminLayout title="Help & Support"> <AdminLayout title="Dispute Management">
<Head title="Help & Support" /> <Head title="Dispute Management" />
<div className="font-sans max-w-[1600px] mx-auto space-y-6"> <div className="font-sans max-w-[1600px] mx-auto space-y-6">
{/* Header Row */} {/* Header Row */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4"> <div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div> <div>
<h1 className="text-2xl font-bold text-slate-900 tracking-tight">Help & Support</h1> <h1 className="text-2xl font-bold text-slate-900 tracking-tight">Dispute Management</h1>
<p className="text-sm text-slate-500 mt-1">Handle complaints and resolve disputes between workers and employers.</p> <p className="text-sm text-slate-500 mt-1">Handle complaints and resolve disputes between workers and sponsors.</p>
</div> </div>
<button className="inline-flex items-center gap-1.5 px-4 py-2.5 bg-[#0F6E56] hover:bg-[#085041] text-white rounded-xl text-xs font-bold transition-all shadow-md self-end md:self-auto uppercase tracking-wider"> <button className="inline-flex items-center gap-1.5 px-4 py-2.5 bg-[#0F6E56] hover:bg-[#085041] text-white rounded-xl text-xs font-bold transition-all shadow-md self-end md:self-auto uppercase tracking-wider">
@ -418,7 +418,7 @@ export default function DisputesHub({ tickets }) {
</div> </div>
<div className="text-[10px] bg-slate-50 p-2.5 rounded-xl border border-slate-100 text-slate-400 font-bold text-center uppercase tracking-wider"> <div className="text-[10px] bg-slate-50 p-2.5 rounded-xl border border-slate-100 text-slate-400 font-bold text-center uppercase tracking-wider">
Logs Digitally Secured by UAE Verification Compliance Logs Digitally Secured by UAE Vetting Compliance
</div> </div>
</div> </div>

Some files were not shown because too many files have changed in this diff Show More