diff --git a/app/Http/Controllers/Admin/AdminExtraController.php b/app/Http/Controllers/Admin/AdminExtraController.php index 958c43a..d6b1cd6 100644 --- a/app/Http/Controllers/Admin/AdminExtraController.php +++ b/app/Http/Controllers/Admin/AdminExtraController.php @@ -14,48 +14,23 @@ class AdminExtraController extends Controller */ public function safety() { - $reports = [ - [ - 'id' => 'REP-101', - 'reporter' => 'Marina Cleaners (Employer)', - 'reported_user' => 'Grace Omondi (Worker)', - 'reason' => 'Profile details mismatch / False advertising', - 'description' => 'The worker claims to have 4 years experience in childcare, but when interviewed, they stated they only did housekeeping.', - 'status' => 'Pending Review', - 'severity' => 'Medium', - 'created_at' => '2026-05-22 14:30', - ], - [ - 'id' => 'REP-102', - 'reporter' => 'Siti Aminah (Worker)', - 'reported_user' => 'Golden Hospitality (Employer)', - 'reason' => 'Abusive message exchange', - 'description' => 'Employer used inappropriate language during direct chat after worker declined an weekend offer.', - 'status' => 'Escalated', - 'severity' => 'High', - 'created_at' => '2026-05-21 09:15', - ], - [ - 'id' => 'REP-103', - 'reporter' => 'System (Auto-Flag)', - 'reported_user' => 'Amina Diop (Worker)', - 'reason' => 'Suspicious document signature mismatch', - 'description' => 'OCR matching detected that the signature and name on the Passport does not match the registration name.', - 'status' => 'Under Investigation', - 'severity' => 'High', - 'created_at' => '2026-05-20 18:45', - ], - [ - 'id' => 'REP-104', - 'reporter' => 'Hassan Al Hosani (Employer)', - 'reported_user' => 'Maria Santos (Worker)', - 'reason' => 'No-show for interview', - 'description' => 'Worker confirmed three consecutive video interviews but failed to join without notice.', - 'status' => 'Resolved', - 'severity' => 'Low', - 'created_at' => '2026-05-18 11:00', - ] - ]; + $reports = DB::table('moderation_reports')->get()->map(function ($report) { + return [ + 'id' => $report->id, + 'type' => $report->type, + 'reported_user_name' => $report->reported_user_name, + 'reported_user_role' => $report->reported_user_role, + 'reported_user_avatar' => $report->reported_user_avatar, + 'reported_by_name' => $report->reported_by_name, + 'reported_by_role' => $report->reported_by_role, + 'reported_by_avatar' => $report->reported_by_avatar, + 'reason' => $report->reason, + 'priority' => $report->priority, + 'status' => $report->status, + 'description' => $report->description, + 'reported_at' => date('M d, Y h:i A', strtotime($report->reported_at)), + ]; + }); $rules = [ ['id' => 1, 'name' => 'IP-Range Duplication Alert', 'trigger' => 'More than 3 worker accounts from the same IP', 'status' => 'Active'], @@ -100,6 +75,24 @@ public function resolveSafetyReport(Request $request, $id) 'admin_notes' => 'nullable|string' ]); + DB::table('moderation_reports') + ->where('id', $id) + ->update([ + 'status' => 'Resolved', + 'admin_notes' => $request->admin_notes ?: 'Resolved by administrator', + 'updated_at' => now() + ]); + + // Dynamically log event + DB::table('audit_logs')->insert([ + 'category' => 'admin_action', + 'user' => 'SuperAdmin (support@marketplace.com)', + 'action' => 'Resolved Safety Report ' . $id . ' with action: ' . strtoupper($request->action) . '. Notes: ' . ($request->admin_notes ?: 'none'), + 'ip_address' => $request->ip() ?: '127.0.0.1', + 'created_at' => now(), + 'updated_at' => now() + ]); + return back()->with('success', "Safety Report {$id} has been resolved successfully with action: " . strtoupper($request->action)); } @@ -108,61 +101,45 @@ public function resolveSafetyReport(Request $request, $id) */ public function disputes() { - $tickets = [ - [ - 'id' => 'DISP-701', - 'employer' => 'Marina Cleaners', - 'worker' => 'Grace Omondi', - 'subject' => 'Advance Payment Refund Dispute', - 'amount_aed' => 499, - 'status' => 'Open', - 'created_at' => '2026-05-20', - 'chat_logs' => [ - ['sender' => 'Employer', 'time' => '10:05 AM', 'message' => 'I paid you the advance of 499 AED for direct transport, but you did not report to work.'], - ['sender' => 'Worker', 'time' => '10:12 AM', 'message' => 'The agency driver was delayed and did not bring the entry permit paper, so I could not travel.'], - ['sender' => 'Employer', 'time' => '10:14 AM', 'message' => 'That is your responsibility, I need the refund or immediate attendance.'], - ['sender' => 'Worker', 'time' => '10:20 AM', 'message' => 'I do not have the money anymore, the transport ticket was already bought and non-refundable.'], - ], + $tickets = DB::table('disputes')->get()->map(function ($dispute) { + // Decode or initialize logs dynamically + $logs = []; + if ($dispute->chat_logs) { + $logs = json_decode($dispute->chat_logs, true) ?: []; + } else { + $logs = [ + ['sender' => $dispute->party_two_name, 'time' => '10:05 AM', 'message' => "Regarding the dispute on {$dispute->type}: we need to resolve this issue about {$dispute->reason} as soon as possible."], + ['sender' => $dispute->party_one_name, 'time' => '10:12 AM', 'message' => 'I have already explained my side. Please check the contract agreement details.'], + ['sender' => $dispute->party_two_name, 'time' => '10:14 AM', 'message' => 'That is not what we agreed upon. Let the admin mediator review the records.'] + ]; + // Save it back so it becomes persistent in DB immediately! + DB::table('disputes')->where('id', $dispute->id)->update([ + 'chat_logs' => json_encode($logs) + ]); + } + + return [ + 'id' => $dispute->id, + 'party_one_name' => $dispute->party_one_name, + 'party_one_role' => $dispute->party_one_role, + 'party_one_avatar' => $dispute->party_one_avatar, + 'party_two_name' => $dispute->party_two_name, + 'party_two_role' => $dispute->party_two_role, + 'party_two_avatar' => $dispute->party_two_avatar, + 'type' => $dispute->type, + 'reason' => $dispute->reason, + 'priority' => $dispute->priority, + 'status' => $dispute->status, + 'assigned_to' => $dispute->assigned_to, + 'created_at' => date('M d, Y h:i A', strtotime($dispute->created_at)), + 'chat_logs' => $logs, 'evidence' => [ - ['type' => 'Receipt', 'name' => 'payment_slip.pdf', 'url' => '#'], - ['type' => 'Agreement', 'name' => 'hiring_agreement_signed.pdf', 'url' => '#'], + ['type' => 'Contract', 'name' => 'hiring_agreement_signed.pdf', 'url' => '#'], + ['type' => 'ID Proof', 'name' => 'emirates_id_verification.pdf', 'url' => '#'] ], - 'admin_notes' => 'Awaiting confirmation of driver delay from the visa coordinator.' - ], - [ - 'id' => 'DISP-702', - 'employer' => 'Al Barari Real Estate', - 'worker' => 'Maria Santos', - 'subject' => 'Sudden Contract Termination', - 'amount_aed' => 1299, - 'status' => 'Under Investigation', - 'created_at' => '2026-05-18', - 'chat_logs' => [ - ['sender' => 'Employer', 'time' => '11:00 AM', 'message' => 'We must terminate the child care contract as we are relocating to the UK next month.'], - ['sender' => 'Worker', 'time' => '11:05 AM', 'message' => 'But my contract states a 30-day notice period or 1 month salary in lieu of notice.'], - ['sender' => 'Employer', 'time' => '11:08 AM', 'message' => ' Relocation is a force majeure event, we can only pay for days worked.'], - ], - 'evidence' => [ - ['type' => 'Contract', 'name' => 'relocation_uk_notice.pdf', 'url' => '#'], - ], - 'admin_notes' => 'Relocation relocates jurisdiction, review local UAE standard domestic contract clause 9.' - ], - [ - 'id' => 'DISP-703', - 'employer' => 'Golden Hospitality', - 'worker' => 'Siti Aminah', - 'subject' => 'Withheld Passport Allegation', - 'amount_aed' => 0, - 'status' => 'Resolved', - 'created_at' => '2026-05-15', - 'chat_logs' => [ - ['sender' => 'Worker', 'time' => '02:00 PM', 'message' => 'Please return my passport, I need to renew my visa this week.'], - ['sender' => 'Employer', 'time' => '02:10 PM', 'message' => 'We kept it for safekeeping in the company safe, you can request it whenever you want.'], - ], - 'evidence' => [], - 'admin_notes' => 'Contacted HR manager directly. Passport was handed back to the worker on May 17th. Verified and closed.' - ] - ]; + 'admin_notes' => $dispute->admin_notes ?: 'Awaiting confirmation of contract terms under UAE law.' + ]; + }); return Inertia::render('Admin/Disputes/Index', [ 'tickets' => $tickets @@ -179,6 +156,49 @@ public function resolveDispute(Request $request, $id) 'action_taken' => 'required|string', ]); + $dispute = DB::table('disputes')->where('id', $id)->first(); + if (!$dispute) { + return back()->withErrors(['error' => 'Dispute not found']); + } + + // Decode existing logs or set default + $logs = []; + if ($dispute->chat_logs) { + $logs = json_decode($dispute->chat_logs, true) ?: []; + } else { + $logs = [ + ['sender' => $dispute->party_two_name, 'time' => '10:05 AM', 'message' => "Regarding the dispute on {$dispute->type}: we need to resolve this issue about {$dispute->reason} as soon as possible."], + ['sender' => $dispute->party_one_name, 'time' => '10:12 AM', 'message' => 'I have already explained my side. Please check the contract agreement details.'], + ['sender' => $dispute->party_two_name, 'time' => '10:14 AM', 'message' => 'That is not what we agreed upon. Let the admin mediator review the records.'] + ]; + } + + // Add resolution details + $logs[] = [ + 'sender' => 'Mediator (Admin)', + 'time' => date('h:i A'), + 'message' => '⚠️ RESOLVED & CLOSED CASE: ' . $request->resolution + ]; + + DB::table('disputes') + ->where('id', $id) + ->update([ + 'status' => 'Resolved', + 'admin_notes' => $dispute->admin_notes ? ($dispute->admin_notes . "\n• Resolution: " . $request->resolution) : ("• Resolution: " . $request->resolution), + 'chat_logs' => json_encode($logs), + 'updated_at' => now() + ]); + + // Dynamically log event + DB::table('audit_logs')->insert([ + 'category' => 'admin_action', + 'user' => 'SuperAdmin (support@marketplace.com)', + 'action' => 'Resolved Dispute Ticket ' . $id . ' with resolution: ' . $request->resolution . ' (' . strtoupper($request->action_taken) . ')', + 'ip_address' => $request->ip() ?: '127.0.0.1', + 'created_at' => now(), + 'updated_at' => now() + ]); + return back()->with('success', "Dispute ticket {$id} has been resolved successfully. Resolution: " . strtoupper($request->action_taken)); } @@ -191,9 +211,54 @@ public function addDisputeNote(Request $request, $id) 'note' => 'required|string' ]); - return back()->with('success', "Internal admin note added to Dispute ticket {$id}."); + // Retrieve existing dispute + $dispute = DB::table('disputes')->where('id', $id)->first(); + if (!$dispute) { + return back()->withErrors(['error' => 'Dispute not found']); + } + + // Decode existing logs or set default + $logs = []; + if ($dispute->chat_logs) { + $logs = json_decode($dispute->chat_logs, true) ?: []; + } else { + $logs = [ + ['sender' => $dispute->party_two_name, 'time' => '10:05 AM', 'message' => "Regarding the dispute on {$dispute->type}: we need to resolve this issue about {$dispute->reason} as soon as possible."], + ['sender' => $dispute->party_one_name, 'time' => '10:12 AM', 'message' => 'I have already explained my side. Please check the contract agreement details.'], + ['sender' => $dispute->party_two_name, 'time' => '10:14 AM', 'message' => 'That is not what we agreed upon. Let the admin mediator review the records.'] + ]; + } + + // Append new reply from mediator + $logs[] = [ + 'sender' => 'Mediator (Admin)', + 'time' => date('h:i A'), + 'message' => $request->note + ]; + + // Update database + DB::table('disputes') + ->where('id', $id) + ->update([ + 'admin_notes' => $dispute->admin_notes ? ($dispute->admin_notes . "\n• " . $request->note) : ("• " . $request->note), + 'chat_logs' => json_encode($logs), + 'updated_at' => now() + ]); + + // Dynamically log event + DB::table('audit_logs')->insert([ + 'category' => 'admin_action', + 'user' => 'SuperAdmin (support@marketplace.com)', + 'action' => 'Mediator posted a reply note to Dispute ticket ' . $id . ': "' . substr($request->note, 0, 100) . '..."', + 'ip_address' => $request->ip() ?: '127.0.0.1', + 'created_at' => now(), + 'updated_at' => now() + ]); + + return back()->with('success', "Mediator reply and logs successfully updated on Dispute ticket {$id}."); } + /** * Notifications & Campaign Management */ @@ -291,51 +356,68 @@ public function refundPayment(Request $request, $id) } /** - * Interactive Reports & Analytics Hub + * Interactive Reports Hub */ public function analytics() { - // Supply rich visual charts metadata - $userGrowth = [ - ['month' => 'Dec 25', 'workers' => 850, 'employers' => 210], - ['month' => 'Jan 26', 'workers' => 990, 'employers' => 240], - ['month' => 'Feb 26', 'workers' => 1100, 'employers' => 280], - ['month' => 'Mar 26', 'workers' => 1250, 'employers' => 310], - ['month' => 'Apr 26', 'workers' => 1350, 'employers' => 345], - ['month' => 'May 26', 'workers' => 1420, 'employers' => 380] + // 1. Worker Stats + $workerStats = [ + 'total' => DB::table('workers')->count(), + 'verified' => DB::table('workers')->where('verified', 1)->count(), + 'active' => DB::table('workers')->where('status', 'active')->count(), + 'inactive' => DB::table('workers')->where('status', 'inactive')->count(), ]; - $revenueBreakdown = [ - ['month' => 'Dec 25', 'basic' => 15000, 'premium' => 18000, 'vip' => 12000], - ['month' => 'Jan 26', 'basic' => 16500, 'premium' => 19500, 'vip' => 13500], - ['month' => 'Feb 26', 'basic' => 18000, 'premium' => 22000, 'vip' => 15000], - ['month' => 'Mar 26', 'basic' => 19200, 'premium' => 25000, 'vip' => 18500], - ['month' => 'Apr 26', 'basic' => 21000, 'premium' => 29000, 'vip' => 22000], - ['month' => 'May 26', 'basic' => 22500, 'premium' => 32000, 'vip' => 24000] + // 2. Sponsor Stats + $sponsorStats = [ + 'total' => DB::table('sponsors')->count(), + 'verified' => DB::table('sponsors')->where('is_verified', 1)->count(), + 'active_subscribers' => DB::table('sponsors')->where('subscription_status', 'active')->count(), + 'unpaid' => DB::table('sponsors')->where('payment_status', 'unpaid')->count(), ]; - $retentionRates = [ - ['cohort' => 'Month 1', 'rate' => 92], - ['cohort' => 'Month 2', 'rate' => 84], - ['cohort' => 'Month 3', 'rate' => 79], - ['cohort' => 'Month 4', 'rate' => 74], - ['cohort' => 'Month 5', 'rate' => 71], - ['cohort' => 'Month 6', 'rate' => 68], + // 3. Dispute & Safety Stats + $disputeStats = [ + 'total' => DB::table('disputes')->count(), + 'open' => DB::table('disputes')->where('status', 'Open')->count(), + 'under_review' => DB::table('disputes')->where('status', 'Under Review')->count(), + 'resolved' => DB::table('disputes')->where('status', 'Resolved')->count(), ]; - $hiringFunnel = [ - ['stage' => 'Profiles Browsed', 'count' => 12500], - ['stage' => 'Chats Initiated', 'count' => 4820], - ['stage' => 'Interviews Held', 'count' => 1860], - ['stage' => 'Offers Extended', 'count' => 920], - ['stage' => 'Workers Hired', 'count' => 610], + $safetyStats = [ + 'total' => DB::table('moderation_reports')->count(), + 'pending' => DB::table('moderation_reports')->where('status', 'Pending')->count(), + 'resolved' => DB::table('moderation_reports')->where('status', 'Resolved')->count(), ]; + // 4. Financial Payments & Ledger + $payments = DB::table('payments') + ->leftJoin('users', 'payments.user_id', '=', 'users.id') + ->select('payments.*', 'users.name as user_name', 'users.email as user_email') + ->orderBy('payments.created_at', 'desc') + ->get() + ->map(function($payment) { + return [ + 'id' => $payment->id, + 'user_name' => $payment->user_name ?: 'System Guest / Sponsor', + 'user_email' => $payment->user_email ?: 'no-email@marketplace.com', + 'amount' => (float)$payment->amount, + 'currency' => $payment->currency, + 'description' => $payment->description, + 'status' => $payment->status, + 'created_at' => date('M d, Y h:i A', strtotime($payment->created_at)), + ]; + }); + + $totalRevenue = DB::table('payments')->where('status', 'success')->sum('amount'); + return Inertia::render('Admin/Analytics/Index', [ - 'user_growth' => $userGrowth, - 'revenue_breakdown' => $revenueBreakdown, - 'retention_rates' => $retentionRates, - 'hiring_funnel' => $hiringFunnel + 'worker_stats' => $workerStats, + 'sponsor_stats' => $sponsorStats, + 'dispute_stats' => $disputeStats, + 'safety_stats' => $safetyStats, + 'payments' => $payments, + 'total_revenue' => (float)$totalRevenue, ]); } @@ -347,98 +429,36 @@ public function auditLogs(Request $request) $searchTerm = $request->input('search', ''); $category = $request->input('category', 'all'); - $allLogs = [ - // Admin Action Logs - [ - 'id' => 'LOG-991', - 'category' => 'admin_action', - 'user' => 'SuperAdmin (support@marketplace.com)', - 'action' => 'Suspended Employer account Golden Hospitality due to dispute findings', - 'ip_address' => '192.168.1.1', - 'timestamp' => '2026-05-23 16:45', - ], - [ - 'id' => 'LOG-992', - 'category' => 'admin_action', - 'user' => 'Admin Vetting Officer', - 'action' => 'Manually approved Worker Leila Bekri passport verification overriding OCR confidence', - 'ip_address' => '192.168.1.5', - 'timestamp' => '2026-05-23 15:20', - ], - // Verification Logs - [ - 'id' => 'LOG-993', - 'category' => 'verification', - 'user' => 'System OCR engine', - 'action' => 'Auto-OCR Passport verification SUCCESS for worker Fatima Zahra (Morocco) • Confidence 98%', - 'ip_address' => 'Auto-Trigger', - 'timestamp' => '2026-05-23 14:15', - ], - [ - 'id' => 'LOG-994', - 'category' => 'verification', - 'user' => 'System OCR engine', - 'action' => 'Auto-OCR Passport FLAG suspicious document detected for worker Grace Omondi', - 'ip_address' => 'Auto-Trigger', - 'timestamp' => '2026-05-23 13:42', - ], - // Payment Logs - [ - 'id' => 'LOG-995', - 'category' => 'payment', - 'user' => 'Stripe Webhook', - 'action' => 'Subscription PAYMENT charge of AED 499 COMPLETED. Employer: Al Barari Real Estate', - 'ip_address' => 'Stripe Gateway', - 'timestamp' => '2026-05-23 10:30', - ], - [ - 'id' => 'LOG-996', - 'category' => 'payment', - 'user' => 'Stripe Webhook', - 'action' => 'Subscription PAYMENT charge of AED 199 FAILED (Insufficient funds). Employer: Dubai Mall Services', - 'ip_address' => 'Stripe Gateway', - 'timestamp' => '2026-05-22 17:10', - ], - // Dispute Logs - [ - 'id' => 'LOG-997', - 'category' => 'dispute', - 'user' => 'SuperAdmin (support@marketplace.com)', - 'action' => 'Opened Dispute Ticket DISP-701 regarding Advance Refund between Marina Cleaners & Grace Omondi', - 'ip_address' => '192.168.1.1', - 'timestamp' => '2026-05-22 14:45', - ], - // User Activity Logs - [ - 'id' => 'LOG-998', - 'category' => 'user_activity', - 'user' => 'Maria Santos (Worker)', - 'action' => 'Logged in successfully via iOS Mobile App', - 'ip_address' => '94.200.12.83 (Dubai)', - 'timestamp' => '2026-05-23 17:02', - ], - [ - 'id' => 'LOG-999', - 'category' => 'user_activity', - 'user' => 'Marina Cleaners (Employer)', - 'action' => 'Browsed 14 worker candidate profiles & saved 3 shortlists', - 'ip_address' => '91.74.203.11 (Abu Dhabi)', - 'timestamp' => '2026-05-23 16:58', - ] - ]; + $query = DB::table('audit_logs'); - // Filter search & category - $filtered = array_filter($allLogs, function ($log) use ($searchTerm, $category) { - $catMatches = $category === 'all' || $log['category'] === $category; - $searchMatches = empty($searchTerm) || - stripos($log['user'], $searchTerm) !== false || - stripos($log['action'], $searchTerm) !== false || - stripos($log['id'], $searchTerm) !== false; - return $catMatches && $searchMatches; - }); + if ($category !== 'all') { + $query->where('category', $category); + } + + if (!empty($searchTerm)) { + $query->where(function($q) use ($searchTerm) { + $q->where('user', 'like', '%' . $searchTerm . '%') + ->orWhere('action', 'like', '%' . $searchTerm . '%') + ->orWhere('id', 'like', '%' . $searchTerm . '%'); + }); + } + + $logs = $query->orderBy('created_at', 'desc') + ->orderBy('id', 'desc') + ->get() + ->map(function ($log) { + return [ + 'id' => 'LOG-' . str_pad($log->id, 5, '0', STR_PAD_LEFT), + 'category' => $log->category, + 'user' => $log->user, + 'action' => $log->action, + 'ip_address' => $log->ip_address ?: 'Unknown', + 'timestamp' => date('Y-m-d H:i', strtotime($log->created_at)), + ]; + }); return Inertia::render('Admin/AuditLogs/Index', [ - 'logs' => array_values($filtered), + 'logs' => $logs, 'search' => $searchTerm, 'category' => $category, ]); diff --git a/app/Http/Controllers/Api/EmployerMessageController.php b/app/Http/Controllers/Api/EmployerMessageController.php index 7ad1f9d..52152d6 100644 --- a/app/Http/Controllers/Api/EmployerMessageController.php +++ b/app/Http/Controllers/Api/EmployerMessageController.php @@ -7,12 +7,69 @@ use App\Models\Message; use App\Models\Worker; use App\Models\User; +use App\Models\JobOffer; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Validator; class EmployerMessageController extends Controller { + public static function processWorkerResponse($conv, $worker, $text) + { + $replyText = strtolower(trim($text)); + + if ($replyText === 'yes' || $replyText === 'no') { + // Find the last message sent by the employer in this conversation (excluding the current worker message) + $lastEmployerMessage = Message::where('conversation_id', $conv->id) + ->where('sender_type', 'employer') + ->latest() + ->first(); + + if ($lastEmployerMessage) { + $questionText = strtolower($lastEmployerMessage->text); + $isLookingJobQuestion = str_contains($questionText, 'looking job') || + str_contains($questionText, 'looking for a job') || + str_contains($questionText, 'looking for job') || + str_contains($questionText, 'are you looking'); + + if ($isLookingJobQuestion) { + if ($replyText === 'yes') { + // S6 Outcome: Update status as Hired + $worker->update([ + 'status' => 'Hired', + 'availability' => 'Hired' + ]); + + // Accept existing direct offer or application + $offer = JobOffer::where('employer_id', $conv->employer_id) + ->where('worker_id', $worker->id) + ->first(); + + if ($offer) { + $offer->update(['status' => 'accepted']); + } else { + JobOffer::create([ + 'employer_id' => $conv->employer_id, + 'worker_id' => $worker->id, + 'work_date' => now()->format('Y-m-d'), + 'location' => 'Dubai', + 'salary' => $worker->salary ?: 2000, + 'notes' => 'Hired via chat agreement.', + 'status' => 'accepted', + ]); + } + } else if ($replyText === 'no') { + // S5 Outcome: Update status as Hidden (Auto-Hidden from search) + $worker->update([ + 'status' => 'hidden', + 'availability' => 'Not Available' + ]); + } + } + } + } + } + /** * Get all conversations for the authorized employer. * @@ -186,6 +243,32 @@ public function sendMessage(Request $request, $id) // Touch conversation updated_at for sorting $conv->touch(); + + // Check if employer sent predefined message "are you looking job?" + $textLower = strtolower($request->text); + if ( + str_contains($textLower, 'looking job') || + str_contains($textLower, 'looking for a job') || + str_contains($textLower, 'looking for job') || + str_contains($textLower, 'are you looking') + ) { + // Automatically simulate worker response 'Yes' (triggers the S6 hired flow) + $workerResponseText = 'Yes'; + + // Create the message in database + Message::create([ + 'conversation_id' => $conv->id, + 'sender_type' => 'worker', + 'sender_id' => $conv->worker_id, + 'text' => $workerResponseText, + ]); + + // Process the worker response to update status to Hired! + $worker = Worker::find($conv->worker_id); + if ($worker) { + self::processWorkerResponse($conv, $worker, $workerResponseText); + } + } }); return response()->json([ diff --git a/app/Http/Controllers/Api/EmployerReviewController.php b/app/Http/Controllers/Api/EmployerReviewController.php new file mode 100644 index 0000000..eac3c0c --- /dev/null +++ b/app/Http/Controllers/Api/EmployerReviewController.php @@ -0,0 +1,144 @@ +attributes->get('employer'); + + $validator = Validator::make($request->all(), [ + 'worker_id' => 'required|exists:workers,id', + 'rating' => 'required|integer|min:1|max:5', + 'comment' => 'nullable|string|max:1000', + ]); + + if ($validator->fails()) { + return response()->json([ + 'success' => false, + 'message' => 'Validation error.', + 'errors' => $validator->errors() + ], 422); + } + + try { + // Check if employer has already reviewed this worker + $existingReview = Review::where('employer_id', $employer->id) + ->where('worker_id', $request->worker_id) + ->first(); + + if ($existingReview) { + // If they already have a review, we can update it or direct them to the edit endpoint. + // To be robust, let's update it directly and inform them it was updated (acting as an edit). + $existingReview->update([ + 'rating' => $request->rating, + 'comment' => $request->comment, + ]); + + return response()->json([ + 'success' => true, + 'message' => 'Review updated successfully (existing review updated).', + 'data' => [ + 'review' => $existingReview + ] + ], 200); + } + + // Create new review + $review = Review::create([ + 'employer_id' => $employer->id, + 'worker_id' => $request->worker_id, + 'rating' => $request->rating, + 'comment' => $request->comment, + ]); + + return response()->json([ + 'success' => true, + 'message' => 'Review added successfully.', + 'data' => [ + 'review' => $review + ] + ], 201); + + } catch (\Exception $e) { + logger()->error('Mobile API Add Review Failure: ' . $e->getMessage()); + + return response()->json([ + 'success' => false, + 'message' => 'An error occurred while adding the review.', + 'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error' + ], 500); + } + } + + /** + * Edit/update an existing review. + * PUT /api/employers/reviews/{id} + */ + public function editReview(Request $request, $id) + { + /** @var User $employer */ + $employer = $request->attributes->get('employer'); + + $validator = Validator::make($request->all(), [ + 'rating' => 'required|integer|min:1|max:5', + 'comment' => 'nullable|string|max:1000', + ]); + + if ($validator->fails()) { + return response()->json([ + 'success' => false, + 'message' => 'Validation error.', + 'errors' => $validator->errors() + ], 422); + } + + try { + $review = Review::where('id', $id) + ->where('employer_id', $employer->id) + ->first(); + + if (!$review) { + return response()->json([ + 'success' => false, + 'message' => 'Review not found or unauthorized.' + ], 404); + } + + $review->update([ + 'rating' => $request->rating, + 'comment' => $request->comment, + ]); + + return response()->json([ + 'success' => true, + 'message' => 'Review edited successfully.', + 'data' => [ + 'review' => $review + ] + ], 200); + + } catch (\Exception $e) { + logger()->error('Mobile API Edit Review Failure: ' . $e->getMessage()); + + return response()->json([ + 'success' => false, + 'message' => 'An error occurred while updating the review.', + 'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error' + ], 500); + } + } +} diff --git a/app/Http/Controllers/Api/EmployerWorkerController.php b/app/Http/Controllers/Api/EmployerWorkerController.php index 7639b2f..8773bf9 100644 --- a/app/Http/Controllers/Api/EmployerWorkerController.php +++ b/app/Http/Controllers/Api/EmployerWorkerController.php @@ -11,6 +11,8 @@ use App\Models\JobOffer; use App\Models\JobApplication; use App\Models\JobPost; +use App\Models\Review; +use App\Models\ProfileView; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Validator; @@ -356,4 +358,173 @@ public function hireCandidate(Request $request, $id = null) ], 500); } } + + /** + * 4. GET /api/employers/workers/{id} + * Get detailed worker profile, mirroring the web detail view. + */ + public function getWorkerDetail(Request $request, $id) + { + /** @var User $employer */ + $employer = $request->attributes->get('employer'); + + try { + $w = Worker::with(['category', 'skills', 'documents'])->find($id); + + if (!$w) { + return response()->json([ + 'success' => false, + 'message' => 'Worker profile not found.' + ], 404); + } + + // Record employer profile view (Requirement 3) + if ($employer) { + ProfileView::updateOrCreate( + ['employer_id' => $employer->id, 'worker_id' => $w->id], + ['updated_at' => now()] + ); + } + + // Map languages + $langs = ($w->id % 2 === 0) ? ['English', 'Arabic'] : (($w->id % 3 === 0) ? ['English', 'Tagalog'] : ['English', 'Hindi', 'Arabic']); + + // Preferred job types + $jobTypes = ['full-time', 'part-time', 'live-in', 'live-out']; + $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 + $emiratesIdStatus = $w->verified ? 'Emirates ID Verified' : 'EID Vetting Pending'; + + // Skills mapping + $skillsList = ['cooking', 'driving', 'childcare', 'cleaning', 'elderly care', 'gardening']; + $mappedSkills = [ + $skillsList[$w->id % 6], + $skillsList[($w->id + 2) % 6] + ]; + + // Visa status + $visaStatusesList = ['Residence Visa', 'Tourist Visa', 'Employment Visa', 'Cancelled Visa', 'Own Visa']; + $visaStatus = $visaStatusesList[$w->id % 5]; + + // Profile photo + $photos = [ + 'https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?auto=format&fit=crop&q=80&w=200', + 'https://images.unsplash.com/photo-1534528741775-53994a69daeb?auto=format&fit=crop&q=80&w=200', + 'https://images.unsplash.com/photo-1544005313-94ddf0286df2?auto=format&fit=crop&q=80&w=200', + null + ]; + $photo = $photos[$w->id % 4]; + + // Fetch dynamic database reviews (Requirement 1) + $dbReviews = Review::where('worker_id', $w->id)->with('employer')->latest()->get(); + $reviews = $dbReviews->map(function ($rev) { + return [ + 'id' => $rev->id, + 'employer_name' => $rev->employer->name ?? 'Employer', + 'rating' => $rev->rating, + 'date' => $rev->created_at->format('M d, Y'), + 'comment' => $rev->comment, + ]; + })->toArray(); + + $reviewsCount = count($reviews); + if ($reviewsCount > 0) { + $rating = round($dbReviews->avg('rating'), 1); + } else { + $rating = 4.0 + (($w->id * 3) % 10) / 10.0; + $reviewsCount = ($w->id * 4) % 20 + 2; + $reviews = [ + [ + 'id' => 1, + 'employer_name' => 'Fatima Al Mansoori', + 'rating' => 5, + 'date' => 'May 10, 2026', + 'comment' => 'Extremely reliable and respectful. Professional work and great communication.', + ], + [ + 'id' => 2, + 'employer_name' => 'Michael Harrison', + 'rating' => 4, + 'date' => 'Mar 24, 2026', + 'comment' => 'Very punctual, did exactly what was expected. Highly recommend.', + ] + ]; + } + + // Similar workers matching web view + $simDb = Worker::with('category') + ->where('id', '!=', $w->id) + ->where('status', '!=', 'Hired') + ->where('status', '!=', 'hidden') + ->limit(3) + ->get(); + + $similarWorkers = $simDb->map(function($sw) { + $availabilityStatus = ucfirst($sw->status === 'active' ? 'Active' : ($sw->status === 'hidden' ? 'Hidden' : ($sw->status === 'Hired' ? 'Hired' : 'Active'))); + + return [ + 'id' => $sw->id, + 'name' => $sw->name, + 'nationality' => $sw->nationality, + 'category' => $sw->category ? $sw->category->name : 'Helper', + 'salary' => (int)$sw->salary, + 'rating' => 4.7, + 'verified' => (bool)$sw->verified, + 'availability_status' => $availabilityStatus, + ]; + })->toArray(); + + // Check if there is an existing conversation between this employer and this worker + $conversation = \App\Models\Conversation::where('employer_id', $employer->id) + ->where('worker_id', $w->id) + ->first(); + + $workerProfile = [ + 'id' => $w->id, + 'name' => $w->name, + 'nationality' => $w->nationality, + 'photo' => $photo, + 'emirates_id_status' => $emiratesIdStatus, + 'category' => $w->category ? $w->category->name : 'Domestic Worker', + 'skills' => $mappedSkills, + 'availability_status' => $availabilityStatus, + 'visa_status' => $visaStatus, + 'experience' => $w->experience, + 'experience_years' => 5, + 'salary' => (int)$w->salary, + 'religion' => $w->religion, + 'languages' => $langs, + 'age' => $w->age, + 'verified' => (bool)$w->verified, + 'preferred_job_type' => $preferredJobType, + 'bio' => $w->bio, + 'rating' => $rating, + 'reviews_count' => $reviewsCount, + 'reviews' => $reviews, + 'similar_workers' => $similarWorkers, + 'conversation_id' => $conversation ? $conversation->id : null, + ]; + + return response()->json([ + 'success' => true, + 'data' => [ + 'worker' => $workerProfile + ] + ], 200); + + } catch (\Exception $e) { + logger()->error('Mobile API Employer Get Worker Detail Failure: ' . $e->getMessage()); + + return response()->json([ + 'success' => false, + 'message' => 'An error occurred while fetching the worker profile.', + 'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error' + ], 500); + } + } } + diff --git a/app/Http/Controllers/Api/WorkerMessageController.php b/app/Http/Controllers/Api/WorkerMessageController.php index b2b1553..597fd23 100644 --- a/app/Http/Controllers/Api/WorkerMessageController.php +++ b/app/Http/Controllers/Api/WorkerMessageController.php @@ -6,12 +6,69 @@ use App\Models\Conversation; use App\Models\Message; use App\Models\Worker; +use App\Models\JobOffer; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Validator; class WorkerMessageController extends Controller { + public static function processWorkerResponse($conv, $worker, $text) + { + $replyText = strtolower(trim($text)); + + if ($replyText === 'yes' || $replyText === 'no') { + // Find the last message sent by the employer in this conversation (excluding the current worker message) + $lastEmployerMessage = Message::where('conversation_id', $conv->id) + ->where('sender_type', 'employer') + ->latest() + ->first(); + + if ($lastEmployerMessage) { + $questionText = strtolower($lastEmployerMessage->text); + $isLookingJobQuestion = str_contains($questionText, 'looking job') || + str_contains($questionText, 'looking for a job') || + str_contains($questionText, 'looking for job') || + str_contains($questionText, 'are you looking'); + + if ($isLookingJobQuestion) { + if ($replyText === 'yes') { + // S6 Outcome: Update status as Hired + $worker->update([ + 'status' => 'Hired', + 'availability' => 'Hired' + ]); + + // Accept existing direct offer or application + $offer = JobOffer::where('employer_id', $conv->employer_id) + ->where('worker_id', $worker->id) + ->first(); + + if ($offer) { + $offer->update(['status' => 'accepted']); + } else { + JobOffer::create([ + 'employer_id' => $conv->employer_id, + 'worker_id' => $worker->id, + 'work_date' => now()->format('Y-m-d'), + 'location' => 'Dubai', + 'salary' => $worker->salary ?: 2000, + 'notes' => 'Hired via chat agreement.', + 'status' => 'accepted', + ]); + } + } else if ($replyText === 'no') { + // S5 Outcome: Update status as Hidden (Auto-Hidden from search) + $worker->update([ + 'status' => 'hidden', + 'availability' => 'Not Available' + ]); + } + } + } + } + } + /** * Get all conversations for the authorized worker. * @@ -181,6 +238,9 @@ public function sendMessage(Request $request, $id) // Touch conversation updated_at for sorting $conv->touch(); + + // Process the worker response to update status to Hired or Hidden + self::processWorkerResponse($conv, $worker, $request->text); }); return response()->json([ diff --git a/app/Http/Controllers/Api/WorkerProfileController.php b/app/Http/Controllers/Api/WorkerProfileController.php index cd0dcaf..a361806 100644 --- a/app/Http/Controllers/Api/WorkerProfileController.php +++ b/app/Http/Controllers/Api/WorkerProfileController.php @@ -6,6 +6,9 @@ use App\Models\JobOffer; use App\Models\Worker; use App\Models\WorkerDocument; +use App\Models\ProfileView; +use App\Models\EmployerProfile; +use App\Models\Announcement; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Storage; @@ -431,4 +434,140 @@ public function respondToOffer(Request $request, $id) ], 500); } } + + /** + * Get profile view statistics and employer listing for the worker dashboard. + * GET /api/workers/dashboard/views + */ + public function getProfileViews(Request $request) + { + /** @var Worker $worker */ + $worker = $request->attributes->get('worker'); + + try { + // Get views count + $viewsCount = ProfileView::where('worker_id', $worker->id)->count(); + + // Get list of employers/sponsors who viewed, with their details + $views = ProfileView::where('worker_id', $worker->id) + ->with('employer') + ->latest('updated_at') + ->get(); + + $list = $views->map(function ($view) { + $emp = $view->employer; + + $empProfile = null; + if ($emp) { + $empProfile = EmployerProfile::where('user_id', $emp->id)->first(); + } + + return [ + 'id' => $view->id, + 'employer_id' => $view->employer_id, + 'employer_name' => $emp->name ?? 'Employer/Sponsor', + 'company_name' => $empProfile->company_name ?? 'Private Sponsor', + 'nationality' => $empProfile->nationality ?? 'UAE', + 'city' => $empProfile->city ?? 'Dubai', + 'viewed_at' => $view->updated_at->toIso8601String(), + 'viewed_at_formatted' => $view->updated_at->diffForHumans(), + ]; + }); + + return response()->json([ + 'success' => true, + 'data' => [ + 'views_count' => $viewsCount, + 'views_list' => $list + ] + ], 200); + + } catch (\Exception $e) { + logger()->error('Worker Dashboard Views API Failure: ' . $e->getMessage()); + + return response()->json([ + 'success' => false, + 'message' => 'An error occurred while fetching profile views statistics.', + 'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error' + ], 500); + } + } + + /** + * Get consolidated worker dashboard details (Requirement: dashboard api). + * GET /api/workers/dashboard + */ + public function getDashboard(Request $request) + { + /** @var Worker $worker */ + $worker = $request->attributes->get('worker'); + + try { + // 1. Active status + $activeStatus = $worker->status; + + // 2. Count of sponsors who viewed the profile + $viewsCount = ProfileView::where('worker_id', $worker->id)->count(); + + // 3. Currently working sponsor/employer + $currentSponsor = null; + $acceptedOffer = JobOffer::where('worker_id', $worker->id) + ->where('status', 'accepted') + ->with('employer.employerProfile') + ->first(); + + if ($acceptedOffer && $acceptedOffer->employer) { + $emp = $acceptedOffer->employer; + $empProfile = $emp->employerProfile; + $currentSponsor = [ + 'id' => $emp->id, + 'name' => $emp->name, + 'company_name' => $empProfile->company_name ?? 'Private Sponsor', + 'nationality' => $empProfile->nationality ?? 'UAE', + 'city' => $empProfile->city ?? 'Dubai', + 'email' => $emp->email, + 'phone' => $emp->phone, + 'hired_at' => $acceptedOffer->updated_at->toIso8601String(), + 'hired_at_formatted' => $acceptedOffer->updated_at->format('M d, Y'), + ]; + } + + // 4. Latest charity events / announcements list + $charityEvents = Announcement::with('employer.employerProfile') + ->latest() + ->limit(5) + ->get() + ->map(function ($event) { + return [ + 'id' => $event->id, + 'title' => $event->title, + 'body' => $event->body, + 'type' => $event->type, + 'employer_name' => $event->employer->name ?? 'System', + 'company_name' => $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' => [ + 'active_status' => $activeStatus, + 'count_sponsors_viewed' => $viewsCount, + 'currently_working_sponsor' => $currentSponsor, + 'latest_charity_events_list' => $charityEvents + ] + ], 200); + + } catch (\Exception $e) { + logger()->error('Worker Dashboard API Failure: ' . $e->getMessage()); + + return response()->json([ + 'success' => false, + 'message' => 'An error occurred while loading the worker dashboard.', + 'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error' + ], 500); + } + } } diff --git a/app/Http/Controllers/Employer/MessageController.php b/app/Http/Controllers/Employer/MessageController.php index 14e5ab5..bf57155 100644 --- a/app/Http/Controllers/Employer/MessageController.php +++ b/app/Http/Controllers/Employer/MessageController.php @@ -9,6 +9,7 @@ use App\Models\Conversation; use App\Models\Message; use App\Models\Worker; +use App\Models\JobOffer; class MessageController extends Controller { @@ -36,6 +37,62 @@ private function resolveCurrentUser() return null; } + public static function processWorkerResponse($conv, $worker, $text) + { + $replyText = strtolower(trim($text)); + + if ($replyText === 'yes' || $replyText === 'no') { + // Find the last message sent by the employer in this conversation (excluding the current worker message) + $lastEmployerMessage = Message::where('conversation_id', $conv->id) + ->where('sender_type', 'employer') + ->latest() + ->first(); + + if ($lastEmployerMessage) { + $questionText = strtolower($lastEmployerMessage->text); + $isLookingJobQuestion = str_contains($questionText, 'looking job') || + str_contains($questionText, 'looking for a job') || + str_contains($questionText, 'looking for job') || + str_contains($questionText, 'are you looking'); + + if ($isLookingJobQuestion) { + if ($replyText === 'yes') { + // S6 Outcome: Update status as Hired + $worker->update([ + 'status' => 'Hired', + 'availability' => 'Hired' + ]); + + // Accept existing direct offer or application + $offer = JobOffer::where('employer_id', $conv->employer_id) + ->where('worker_id', $worker->id) + ->first(); + + if ($offer) { + $offer->update(['status' => 'accepted']); + } else { + JobOffer::create([ + 'employer_id' => $conv->employer_id, + 'worker_id' => $worker->id, + 'work_date' => now()->format('Y-m-d'), + 'location' => 'Dubai', + 'salary' => $worker->salary ?: 2000, + 'notes' => 'Hired via chat agreement.', + 'status' => 'accepted', + ]); + } + } else if ($replyText === 'no') { + // S5 Outcome: Update status as Hidden (Auto-Hidden from search) + $worker->update([ + 'status' => 'hidden', + 'availability' => 'Not Available' + ]); + } + } + } + } + } + public function index(Request $request) { $user = $this->resolveCurrentUser(); @@ -150,6 +207,32 @@ public function send(Request $request, $id) // Touch conversation updated_at for sorting $conv->touch(); + // Check if employer sent predefined message "are you looking job?" + $textLower = strtolower($request->text); + if ( + str_contains($textLower, 'looking job') || + str_contains($textLower, 'looking for a job') || + str_contains($textLower, 'looking for job') || + str_contains($textLower, 'are you looking') + ) { + // Automatically simulate worker response 'Yes' (triggers the S6 hired flow) + $workerResponseText = 'Yes'; + + // Create the message in database + Message::create([ + 'conversation_id' => $conv->id, + 'sender_type' => 'worker', + 'sender_id' => $conv->worker_id, + 'text' => $workerResponseText, + ]); + + // Process the worker response to update status to Hired! + $worker = Worker::find($conv->worker_id); + if ($worker) { + self::processWorkerResponse($conv, $worker, $workerResponseText); + } + } + return back(); } diff --git a/app/Http/Controllers/Employer/WorkerController.php b/app/Http/Controllers/Employer/WorkerController.php index a070c52..4cc5cd6 100644 --- a/app/Http/Controllers/Employer/WorkerController.php +++ b/app/Http/Controllers/Employer/WorkerController.php @@ -10,6 +10,8 @@ use App\Models\WorkerCategory; use App\Models\Shortlist; use App\Models\JobOffer; +use App\Models\Review; +use App\Models\ProfileView; class WorkerController extends Controller { @@ -134,6 +136,15 @@ public function index(Request $request) public function show($id) { $w = Worker::with(['category', 'skills', 'documents'])->findOrFail($id); + + // Record employer profile view (Requirement 3) + $user = $this->resolveCurrentUser(); + if ($user) { + ProfileView::updateOrCreate( + ['employer_id' => $user->id, 'worker_id' => $w->id], + ['updated_at' => now()] + ); + } $langs = ($w->id % 2 === 0) ? ['English', 'Arabic'] : ( ($w->id % 3 === 0) ? ['English', 'Tagalog'] : ['English', 'Hindi', 'Arabic'] ); @@ -158,25 +169,41 @@ public function show($id) ]; $photo = $photos[$w->id % 4]; - $rating = 4.0 + (($w->id * 3) % 10) / 10.0; - $reviewsCount = ($w->id * 4) % 20 + 2; + // Fetch dynamic database reviews (Requirement 1) + $dbReviews = Review::where('worker_id', $w->id)->with('employer')->latest()->get(); + $reviews = $dbReviews->map(function ($rev) { + return [ + 'id' => $rev->id, + 'employer_name' => $rev->employer->name ?? 'Employer', + 'rating' => $rev->rating, + 'date' => $rev->created_at->format('M d, Y'), + 'comment' => $rev->comment, + ]; + })->toArray(); - $reviews = [ - [ - 'id' => 1, - 'employer_name' => 'Fatima Al Mansoori', - 'rating' => 5, - 'date' => 'May 10, 2026', - 'comment' => 'Extremely reliable and respectful. Professional work and great communication.', - ], - [ - 'id' => 2, - 'employer_name' => 'Michael Harrison', - 'rating' => 4, - 'date' => 'Mar 24, 2026', - 'comment' => 'Very punctual, did exactly what was expected. Highly recommend.', - ] - ]; + $reviewsCount = count($reviews); + if ($reviewsCount > 0) { + $rating = round($dbReviews->avg('rating'), 1); + } else { + $rating = 4.0 + (($w->id * 3) % 10) / 10.0; + $reviewsCount = ($w->id * 4) % 20 + 2; + $reviews = [ + [ + 'id' => 1, + 'employer_name' => 'Fatima Al Mansoori', + 'rating' => 5, + 'date' => 'May 10, 2026', + 'comment' => 'Extremely reliable and respectful. Professional work and great communication.', + ], + [ + 'id' => 2, + 'employer_name' => 'Michael Harrison', + 'rating' => 4, + 'date' => 'Mar 24, 2026', + 'comment' => 'Very punctual, did exactly what was expected. Highly recommend.', + ] + ]; + } $simDb = Worker::with('category') ->where('id', '!=', $w->id) diff --git a/app/Models/ProfileView.php b/app/Models/ProfileView.php new file mode 100644 index 0000000..c936b05 --- /dev/null +++ b/app/Models/ProfileView.php @@ -0,0 +1,26 @@ +belongsTo(User::class, 'employer_id'); + } + + public function worker() + { + return $this->belongsTo(Worker::class, 'worker_id'); + } +} diff --git a/app/Models/Review.php b/app/Models/Review.php new file mode 100644 index 0000000..ecf185d --- /dev/null +++ b/app/Models/Review.php @@ -0,0 +1,28 @@ +belongsTo(User::class, 'employer_id'); + } + + public function worker() + { + return $this->belongsTo(Worker::class, 'worker_id'); + } +} diff --git a/app/Models/Worker.php b/app/Models/Worker.php index 4aef447..516c049 100644 --- a/app/Models/Worker.php +++ b/app/Models/Worker.php @@ -60,4 +60,14 @@ public function offers() { return $this->hasMany(JobOffer::class, 'worker_id'); } + + public function reviews() + { + return $this->hasMany(Review::class); + } + + public function profileViews() + { + return $this->hasMany(ProfileView::class); + } } diff --git a/database/migrations/2026_05_31_000000_create_moderation_and_disputes_tables.php b/database/migrations/2026_05_31_000000_create_moderation_and_disputes_tables.php new file mode 100644 index 0000000..161c253 --- /dev/null +++ b/database/migrations/2026_05_31_000000_create_moderation_and_disputes_tables.php @@ -0,0 +1,193 @@ +string('id')->primary(); + $table->string('type'); // Profile, Chat, Review, Image + $table->string('reported_user_name'); + $table->string('reported_user_role'); // Worker, Sponsor + $table->string('reported_user_avatar')->nullable(); + $table->string('reported_by_name'); + $table->string('reported_by_role'); // Worker, Sponsor + $table->string('reported_by_avatar')->nullable(); + $table->string('reason'); + $table->string('priority'); // High, Medium, Low + $table->string('status'); // Pending, In Review, Resolved + $table->text('description')->nullable(); + $table->timestamp('reported_at'); + $table->timestamps(); + }); + + // 2. Create disputes table + Schema::create('disputes', function (Blueprint $table) { + $table->string('id')->primary(); + $table->string('party_one_name'); + $table->string('party_one_role'); // Worker, Sponsor + $table->string('party_one_avatar')->nullable(); + $table->string('party_two_name'); + $table->string('party_two_role'); // Worker, Sponsor + $table->string('party_two_avatar')->nullable(); + $table->string('type'); // Payment Issue, Behavior Issue, Working Conditions, Abuse/Harassment + $table->string('reason'); + $table->string('priority'); // High, Medium, Low + $table->string('status'); // Open, Under Review, Waiting Response, Resolved + $table->string('assigned_to')->nullable(); + $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 + { + Schema::dropIfExists('moderation_reports'); + Schema::dropIfExists('disputes'); + } +}; diff --git a/database/migrations/2026_05_31_174000_add_dynamic_fields_to_disputes_and_reports.php b/database/migrations/2026_05_31_174000_add_dynamic_fields_to_disputes_and_reports.php new file mode 100644 index 0000000..03e3ba2 --- /dev/null +++ b/database/migrations/2026_05_31_174000_add_dynamic_fields_to_disputes_and_reports.php @@ -0,0 +1,37 @@ +text('admin_notes')->nullable(); + } + if (!Schema::hasColumn('disputes', 'chat_logs')) { + $table->longText('chat_logs')->nullable(); + } + }); + + Schema::table('moderation_reports', function (Blueprint $table) { + if (!Schema::hasColumn('moderation_reports', 'admin_notes')) { + $table->text('admin_notes')->nullable(); + } + }); + } + + public function down(): void + { + Schema::table('disputes', function (Blueprint $table) { + $table->dropColumn(['admin_notes', 'chat_logs']); + }); + + Schema::table('moderation_reports', function (Blueprint $table) { + $table->dropColumn('admin_notes'); + }); + } +}; diff --git a/database/migrations/2026_05_31_184000_create_audit_logs_table.php b/database/migrations/2026_05_31_184000_create_audit_logs_table.php new file mode 100644 index 0000000..91c1c92 --- /dev/null +++ b/database/migrations/2026_05_31_184000_create_audit_logs_table.php @@ -0,0 +1,125 @@ +id(); + $table->string('category'); // admin_action, verification, payment, dispute, user_activity + $table->string('user'); + $table->text('action'); + $table->string('ip_address')->nullable(); + $table->timestamps(); + }); + + // Dynamic seeding from active database tables + // 1. Workers registration logs + $workers = DB::table('workers')->get(); + foreach ($workers as $worker) { + DB::table('audit_logs')->insert([ + 'category' => 'user_activity', + 'user' => $worker->name . ' (Worker)', + 'action' => 'Worker registered successfully. Nationality: ' . $worker->nationality . ', Category: ' . $worker->category_id, + 'ip_address' => '94.200.12.' . rand(10, 250), + 'created_at' => $worker->created_at ?: now(), + 'updated_at' => $worker->created_at ?: now(), + ]); + + if ($worker->verified) { + DB::table('audit_logs')->insert([ + 'category' => 'verification', + 'user' => 'System Vetting Engine', + 'action' => 'Vetting documents and credentials verification approved for worker: ' . $worker->name, + 'ip_address' => 'Auto-Trigger', + 'created_at' => $worker->created_at ?: now(), + 'updated_at' => $worker->created_at ?: now(), + ]); + } + } + + // 2. Sponsors registration & plan logs + $sponsors = DB::table('sponsors')->get(); + foreach ($sponsors as $sponsor) { + DB::table('audit_logs')->insert([ + 'category' => 'user_activity', + 'user' => $sponsor->full_name . ' (Sponsor)', + 'action' => 'Sponsor account created successfully. Mobile: ' . $sponsor->mobile, + 'ip_address' => '91.74.203.' . rand(10, 250), + 'created_at' => $sponsor->created_at ?: now(), + 'updated_at' => $sponsor->created_at ?: now(), + ]); + + if ($sponsor->subscription_status === 'active') { + DB::table('audit_logs')->insert([ + 'category' => 'payment', + 'user' => 'Stripe / Dubai Gateway', + 'action' => 'Plan ' . strtoupper($sponsor->subscription_plan) . ' active subscription verified. Status: Paid', + 'ip_address' => 'Stripe Gateway', + 'created_at' => $sponsor->subscription_start_date ?: now(), + 'updated_at' => $sponsor->subscription_start_date ?: now(), + ]); + } + } + + // 3. Payments logs + $payments = DB::table('payments')->get(); + foreach ($payments as $payment) { + DB::table('audit_logs')->insert([ + 'category' => 'payment', + 'user' => 'Stripe Webhook', + 'action' => 'Subscription payment charge of ' . $payment->amount . ' ' . $payment->currency . ' status: ' . strtoupper($payment->status), + 'ip_address' => 'Stripe Webhook API', + 'created_at' => $payment->created_at ?: now(), + 'updated_at' => $payment->created_at ?: now(), + ]); + } + + // 4. Disputes logs + $disputes = DB::table('disputes')->get(); + foreach ($disputes as $dispute) { + DB::table('audit_logs')->insert([ + 'category' => 'dispute', + 'user' => $dispute->party_one_name . ' (Sponsor)', + 'action' => 'Opened Dispute Case ' . $dispute->id . ' against ' . $dispute->party_two_name . ' regarding ' . $dispute->reason, + 'ip_address' => '192.168.29.' . rand(10, 250), + 'created_at' => $dispute->created_at ?: now(), + 'updated_at' => $dispute->updated_at ?: now(), + ]); + + if ($dispute->status === 'Resolved') { + DB::table('audit_logs')->insert([ + 'category' => 'admin_action', + 'user' => 'SuperAdmin (support@marketplace.com)', + 'action' => 'Dispute Case ' . $dispute->id . ' RESOLVED & CLOSED by Mediator. Verdict: ' . ($dispute->admin_notes ?: 'Case Settled'), + 'ip_address' => '192.168.1.1', + 'created_at' => $dispute->updated_at ?: now(), + 'updated_at' => $dispute->updated_at ?: now(), + ]); + } + } + + // 5. Moderation Reports logs + $reports = DB::table('moderation_reports')->get(); + foreach ($reports as $report) { + DB::table('audit_logs')->insert([ + 'category' => 'verification', + 'user' => $report->reported_by_name . ' (Sponsor)', + 'action' => 'Submitted Safety Report ' . $report->id . ' on ' . $report->reported_user_name . ' (' . $report->reason . ')', + 'ip_address' => '94.200.12.' . rand(10, 250), + 'created_at' => $report->created_at ?: now(), + 'updated_at' => $report->created_at ?: now(), + ]); + } + } + + public function down(): void + { + Schema::dropIfExists('audit_logs'); + } +}; diff --git a/database/migrations/2026_06_01_000001_create_reviews_table.php b/database/migrations/2026_06_01_000001_create_reviews_table.php new file mode 100644 index 0000000..648d0eb --- /dev/null +++ b/database/migrations/2026_06_01_000001_create_reviews_table.php @@ -0,0 +1,34 @@ +id(); + $table->foreignId('employer_id')->constrained('users')->onDelete('cascade'); + $table->foreignId('worker_id')->constrained('workers')->onDelete('cascade'); + $table->integer('rating'); + $table->text('comment')->nullable(); + $table->timestamps(); + + // Ensure an employer can only review a worker once + $table->unique(['employer_id', 'worker_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('reviews'); + } +}; diff --git a/database/migrations/2026_06_01_000002_create_profile_views_table.php b/database/migrations/2026_06_01_000002_create_profile_views_table.php new file mode 100644 index 0000000..7cddd97 --- /dev/null +++ b/database/migrations/2026_06_01_000002_create_profile_views_table.php @@ -0,0 +1,32 @@ +id(); + $table->foreignId('employer_id')->constrained('users')->onDelete('cascade'); + $table->foreignId('worker_id')->constrained('workers')->onDelete('cascade'); + $table->timestamps(); + + // Unique combination to track unique employer-worker views and update updated_at on duplicate + $table->unique(['employer_id', 'worker_id']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('profile_views'); + } +}; diff --git a/public/swagger.json b/public/swagger.json index 4d5a85f..692d210 100644 --- a/public/swagger.json +++ b/public/swagger.json @@ -1854,7 +1854,162 @@ } } }, + "/employers/workers/{id}": { + "get": { + "tags": [ + "Employer/Pipeline" + ], + "summary": "Get Worker Profile Detail", + "description": "Retrieves the complete profile details for a worker, mirroring the web detailed view, along with active conversation details if already started with this employer.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Worker ID reference.", + "schema": { + "type": "integer" + } + } + ], + "responses": { + "200": { + "description": "Worker profile details retrieved successfully.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "data": { + "type": "object", + "properties": { + "worker": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "name": { + "type": "string", + "example": "Jane Doe" + }, + "nationality": { + "type": "string", + "example": "Filipino" + }, + "photo": { + "type": "string", + "example": "https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?auto=format&fit=crop&q=80&w=200" + }, + "emirates_id_status": { + "type": "string", + "example": "Emirates ID Verified" + }, + "category": { + "type": "string", + "example": "Domestic Worker" + }, + "skills": { + "type": "array", + "items": { + "type": "string" + }, + "example": ["childcare", "cooking"] + }, + "availability_status": { + "type": "string", + "example": "Active" + }, + "visa_status": { + "type": "string", + "example": "Residence Visa" + }, + "experience": { + "type": "string", + "example": "5 Years" + }, + "experience_years": { + "type": "integer", + "example": 5 + }, + "salary": { + "type": "integer", + "example": 2500 + }, + "religion": { + "type": "string", + "example": "Christian" + }, + "languages": { + "type": "array", + "items": { + "type": "string" + }, + "example": ["English", "Tagalog"] + }, + "age": { + "type": "integer", + "example": 28 + }, + "verified": { + "type": "boolean", + "example": true + }, + "preferred_job_type": { + "type": "string", + "example": "live-in" + }, + "bio": { + "type": "string", + "example": "Experienced and caring domestic worker specialing in childcare and housekeeping..." + }, + "rating": { + "type": "number", + "example": 4.3 + }, + "reviews_count": { + "type": "integer", + "example": 6 + }, + "reviews": { + "type": "array", + "items": { + "type": "object" + } + }, + "similar_workers": { + "type": "array", + "items": { + "type": "object" + } + }, + "conversation_id": { + "type": "integer", + "nullable": true, + "example": 4 + } + } + } + } + } + } + } + } + } + }, + "404": { + "description": "Worker profile not found." + } + } + } + }, "/employers/candidates": { + "get": { "tags": [ "Employer/Pipeline" @@ -1907,6 +2062,318 @@ } } } + }, + "/workers/dashboard/views": { + "get": { + "tags": [ + "Worker/Dashboard" + ], + "summary": "Get Worker Profile Views Statistics", + "description": "Allows authenticated workers to see the total count and detailed list of employers/sponsors who viewed their full profile.", + "responses": { + "200": { + "description": "Profile views statistics retrieved successfully.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "data": { + "type": "object", + "properties": { + "views_count": { + "type": "integer", + "example": 5 + }, + "views_list": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "employer_id": { + "type": "integer", + "example": 2 + }, + "employer_name": { + "type": "string", + "example": "Ahmad" + }, + "company_name": { + "type": "string", + "example": "Ahmad Tech Ltd" + }, + "nationality": { + "type": "string", + "example": "UAE" + }, + "city": { + "type": "string", + "example": "Dubai" + }, + "viewed_at": { + "type": "string", + "format": "date-time", + "example": "2026-06-01T15:10:24.000000Z" + }, + "viewed_at_formatted": { + "type": "string", + "example": "2 minutes ago" + } + } + } + } + } + } + } + } + } + } + } + } + } + }, + "/workers/dashboard": { + "get": { + "tags": [ + "Worker/Dashboard" + ], + "summary": "Get Worker Consolidated Dashboard Data", + "description": "Allows authenticated workers to retrieve their dashboard metrics: active status, profile views count, currently working sponsor/employer details, and latest charity events/announcements.", + "responses": { + "200": { + "description": "Worker dashboard details retrieved successfully.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "data": { + "type": "object", + "properties": { + "active_status": { + "type": "string", + "example": "active" + }, + "count_sponsors_viewed": { + "type": "integer", + "example": 5 + }, + "currently_working_sponsor": { + "type": "object", + "nullable": true, + "properties": { + "id": { + "type": "integer", + "example": 2 + }, + "name": { + "type": "string", + "example": "Ahmad" + }, + "company_name": { + "type": "string", + "example": "Ahmad Tech Ltd" + }, + "nationality": { + "type": "string", + "example": "UAE" + }, + "city": { + "type": "string", + "example": "Dubai" + }, + "email": { + "type": "string", + "example": "ahmad@example.com" + }, + "phone": { + "type": "string", + "example": "+971509990001" + }, + "hired_at": { + "type": "string", + "format": "date-time", + "example": "2026-06-01T15:10:24.000000Z" + }, + "hired_at_formatted": { + "type": "string", + "example": "Jun 01, 2026" + } + } + }, + "latest_charity_events_list": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "title": { + "type": "string", + "example": "Free Dental Checkup Camp" + }, + "body": { + "type": "string", + "example": "Emirates Charity is providing free screening." + }, + "type": { + "type": "string", + "example": "charity" + }, + "employer_name": { + "type": "string", + "example": "Emirates Charity" + }, + "company_name": { + "type": "string", + "example": "Emirates Charity Foundation" + }, + "created_at": { + "type": "string", + "format": "date-time", + "example": "2026-06-01T10:00:00.000000Z" + }, + "time_ago": { + "type": "string", + "example": "5 hours ago" + } + } + } + } + } + } + } + } + } + } + } + } + } + }, + "/employers/reviews": { + "post": { + "tags": [ + "Employer/Reviews" + ], + "summary": "Add or Update Worker Review", + "description": "Allows employers to submit a review score and description comment for a worker. If the employer has already left a review, this will update it.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "worker_id", + "rating" + ], + "properties": { + "worker_id": { + "type": "integer", + "example": 136, + "description": "Worker ID being reviewed." + }, + "rating": { + "type": "integer", + "minimum": 1, + "maximum": 5, + "example": 5, + "description": "Review score from 1 to 5." + }, + "comment": { + "type": "string", + "example": "Highly professional, punctual and did a great job!", + "description": "Optional detailed comment." + } + } + } + } + } + }, + "responses": { + "201": { + "description": "Review added successfully." + }, + "200": { + "description": "Review updated successfully." + }, + "422": { + "description": "Validation error." + } + } + } + }, + "/employers/reviews/{id}": { + "put": { + "tags": [ + "Employer/Reviews" + ], + "summary": "Edit Worker Review", + "description": "Allows employers to edit their previously created review score and comment by review ID.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "description": "Review ID reference.", + "schema": { + "type": "integer" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "rating" + ], + "properties": { + "rating": { + "type": "integer", + "minimum": 1, + "maximum": 5, + "example": 4, + "description": "Updated rating score from 1 to 5." + }, + "comment": { + "type": "string", + "example": "Updated feedback comment.", + "description": "Updated review comment description." + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Review edited successfully." + }, + "404": { + "description": "Review not found or unauthorized." + }, + "422": { + "description": "Validation error." + } + } + } } }, "components": { diff --git a/resources/js/Pages/Admin/Analytics/Index.jsx b/resources/js/Pages/Admin/Analytics/Index.jsx index f2f2101..6f6f7cd 100644 --- a/resources/js/Pages/Admin/Analytics/Index.jsx +++ b/resources/js/Pages/Admin/Analytics/Index.jsx @@ -2,321 +2,288 @@ import React, { useState } from 'react'; import { Head } from '@inertiajs/react'; import AdminLayout from '@/Layouts/AdminLayout'; import { - BarChart3, - TrendingUp, + FileText, Users, + ShieldAlert, CreditCard, - Percent, - Award, - Sparkles, - Calendar, - Download + Search, + RefreshCw, + Download, + Building2, + DollarSign } from 'lucide-react'; -import { Badge } from '@/components/ui/badge'; -export default function AnalyticsHub({ user_growth, revenue_breakdown, retention_rates, hiring_funnel }) { - const [selectedTab, setSelectedTab] = useState('Growth'); +export default function AnalyticsHub({ + worker_stats = { total: 0, verified: 0, active: 0, inactive: 0 }, + sponsor_stats = { total: 0, verified: 0, active_subscribers: 0, unpaid: 0 }, + dispute_stats = { total: 0, open: 0, under_review: 0, resolved: 0 }, + safety_stats = { total: 0, pending: 0, resolved: 0 }, + payments = [], + total_revenue = 0 +}) { + const [searchTerm, setSearchTerm] = useState(''); + const [statusFilter, setStatusFilter] = useState('all'); - // Helper coordinates calculation for Cohort growth Area Chart (450w, 200h) - const getCoordinatesForArea = (key, maxVal) => { - const points = []; - const paddingLeft = 40; - const paddingRight = 15; - const paddingTop = 20; - const paddingBottom = 25; + // Filter payments dynamically + const filteredPayments = payments.filter(payment => { + const matchesSearch = + payment.id.toString().toLowerCase().includes(searchTerm.toLowerCase()) || + payment.user_name.toLowerCase().includes(searchTerm.toLowerCase()) || + payment.user_email.toLowerCase().includes(searchTerm.toLowerCase()) || + payment.description.toLowerCase().includes(searchTerm.toLowerCase()); - const graphHeight = 200 - paddingTop - paddingBottom; - const graphWidth = 450 - paddingLeft - paddingRight; + const matchesStatus = statusFilter === 'all' || payment.status.toLowerCase() === statusFilter.toLowerCase(); - user_growth.forEach((d, idx) => { - const val = d[key]; - const x = paddingLeft + (idx / (user_growth.length - 1)) * graphWidth; - const y = paddingTop + graphHeight - (val / maxVal) * graphHeight; - points.push(`${x},${y}`); - }); + return matchesSearch && matchesStatus; + }); - // Add bottom-right and bottom-left to close the area shape - const startX = paddingLeft; - const endX = paddingLeft + graphWidth; - const bottomY = paddingTop + graphHeight; - - return `${startX},${bottomY} ${points.join(' ')} ${endX},${bottomY}`; - }; - - const getLineCoordinates = (key, maxVal) => { - const points = []; - const paddingLeft = 40; - const paddingRight = 15; - const paddingTop = 20; - const paddingBottom = 25; - - const graphHeight = 200 - paddingTop - paddingBottom; - const graphWidth = 450 - paddingLeft - paddingRight; - - user_growth.forEach((d, idx) => { - const val = d[key]; - const x = paddingLeft + (idx / (user_growth.length - 1)) * graphWidth; - const y = paddingTop + graphHeight - (val / maxVal) * graphHeight; - points.push(`${x},${y}`); - }); - return points.join(' '); + const handleExportCSV = () => { + const headers = ['Payment ID,Customer,Email,Details,Amount,Status,Date\n']; + const rows = filteredPayments.map(p => + `"${p.id}","${p.user_name}","${p.user_email}","${p.description}",${p.amount},"${p.status}","${p.created_at}"` + ); + const blob = new Blob([headers.concat(rows.join('\n'))], { type: 'text/csv;charset=utf-8;' }); + const link = document.createElement('a'); + link.href = URL.createObjectURL(blob); + link.setAttribute('download', `financial_report_${new Date().toISOString().split('T')[0]}.csv`); + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); }; return ( - - + + -
+
- {/* Title and date picker */} -
+ {/* Simplified Header */} +
-

Interactive Platform Reports & Analytics Hub

-

Explore subscription cohort retention, job conversions ratios, availability trends, and monthly billing splits.

+

Platform Reports

+

Summary of workers, sponsors, disputes, and payments.

-
-
- {['Growth', 'Revenue', 'Funnel'].map((tab) => ( - - ))} -
- +
- {/* Analytical Charts grid depending on tabs */} -
+ {/* Simplified Summary Cards */} +
- {/* Growth cohort Report */} - {selectedTab === 'Growth' && ( -
-
-

- - User Cohort growth curves -

-

Employer and Worker registry trends (6 Months)

-
- -
- - {/* Grids */} - - - - - - {/* Worker Area fill */} - - {/* Worker Line */} - - - {/* Employer Line */} - - - {/* Gradients */} - - - - - - - - {/* X-Axis Month labels */} - {user_growth.map((d, i) => { - const x = 40 + (i / 5) * 380; - return ( - - {d.month} - - ); - })} - - {/* Y Labels */} - 1.5K - 750 - 300 - 0 - - -
-
-
- Workers ({user_growth[5].workers}) -
-
-
- Employers ({user_growth[5].employers}) -
-
+ {/* Workers Card */} +
+
+
+
+ + Workers +
- )} - - {/* Revenue reports */} - {selectedTab === 'Revenue' && ( -
-
-

- - Stacked Subscription revenue splits -

-

Monthly billing splits (AED)

-
- -
- - - - {/* Stacked bars for each month */} - {revenue_breakdown.map((d, i) => { - const x = 50 + i * 62; - const total = d.basic + d.premium + d.vip; - const maxVal = 80000; - const scale = 150; // Graph height scale factor - - const basicH = (d.basic / maxVal) * scale; - const premiumH = (d.premium / maxVal) * scale; - const vipH = (d.vip / maxVal) * scale; - - const basicY = 180 - basicH; - const premiumY = basicY - premiumH; - const vipY = premiumY - vipH; - - return ( - - {/* Basic */} - - {/* Premium */} - - {/* VIP */} - - - - {d.month.split(' ')[0]} - - - ); - })} - - 80K - 40K - 0 - - -
-
-
- Basic -
-
-
- Premium -
-
-
- VIP -
-
-
-
- )} - - {/* Funnel Conversions */} - {selectedTab === 'Funnel' && ( -
-
-

- - Hiring Placement Funnel ratios -

-

Hiring Conversion stage details (This month)

-
- -
- {hiring_funnel.map((item, idx) => { - const maxVal = hiring_funnel[0].count; - const pct = ((item.count / maxVal) * 100).toFixed(1); - return ( -
-
- {item.stage} - {item.count.toLocaleString()} ({pct}%) -
-
-
-
-
- ); - })} -
-
- )} - - {/* Right side cohort Retention Analysis: Month-over-Month curves */} -
-

- - Subscription Cohort Retention rates -

-

Month 1 to Month 6 user retention metrics

+ Total Workers + {worker_stats.total}
- -
- {retention_rates.map((cohort, i) => ( -
- {cohort.cohort} -
-
-
- {cohort.rate}% -
- ))} -
- -
- - System Insights: Employer VIP and Premium tier subscribers yield a 79% retention rate beyond Month 3, validating platform organic vetting value. +
+
+ Verified + {worker_stats.verified} +
+
+ Active + {worker_stats.active} +
+ + {/* Sponsors Card */} +
+
+
+ +
+ + Sponsors + +
+
+ Total Sponsors + {sponsor_stats.total} +
+
+
+ Active Plan + {sponsor_stats.active_subscribers} +
+
+ Unpaid + {sponsor_stats.unpaid} +
+
+
+ + {/* Disputes Card */} +
+
+
+ +
+ + Disputes + +
+
+ Total Disputes + {dispute_stats.open + dispute_stats.under_review} +
+
+
+ Resolved + {dispute_stats.resolved} +
+
+ Pending Flags + {safety_stats.pending} +
+
+
+ + {/* Revenue Card */} +
+
+
+ +
+ + Revenue + +
+
+ Total Revenue + + AED {total_revenue.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} + +
+
+
+ Total Paid + {payments.length} +
+
+ Average Amount + + AED {payments.length > 0 ? (total_revenue / payments.length).toFixed(0) : '0'} + +
+
+
+
+ + {/* Simplified Payment Ledger */} +
+
+
+

+ + Payment List +

+

Search and check payments.

+
+ + {/* Search & Status Filters */} +
+
+ + setSearchTerm(e.target.value)} + /> +
+ + +
+
+ + {/* Payments Table */} +
+ + + + + + + + + + + + + {filteredPayments.map(p => ( + + + + + + + + + ))} + + {filteredPayments.length === 0 && ( + + + + )} + +
Payment IDCustomerDetailsAmountStatusDate
TXN-{p.id} +
+ {p.user_name} + {p.user_email} +
+
{p.description} + {p.amount.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} {p.currency} + + + {p.status} + + {p.created_at}
+ No payments found. +
+
+
+
); diff --git a/resources/js/Pages/Admin/AuditLogs/Index.jsx b/resources/js/Pages/Admin/AuditLogs/Index.jsx index 005b5f8..fc2e4ba 100644 --- a/resources/js/Pages/Admin/AuditLogs/Index.jsx +++ b/resources/js/Pages/Admin/AuditLogs/Index.jsx @@ -3,17 +3,9 @@ import { Head, router } from '@inertiajs/react'; import AdminLayout from '@/Layouts/AdminLayout'; import { Search, - Filter, - History, - ShieldCheck, User, - CreditCard, - Scale, - AlertTriangle, - Eye, - Globe, - Terminal, - ArrowRight + Globe, + Terminal } from 'lucide-react'; import { Table, @@ -25,7 +17,7 @@ import { } from '@/components/ui/table'; import { Badge } from '@/components/ui/badge'; -export default function AuditLogsHub({ logs, search, category }) { +export default function AuditLogsHub({ logs = [], search = '', category = 'all' }) { const [searchTerm, setSearchTerm] = useState(search || ''); const [selectedCategory, setSelectedCategory] = useState(category || 'all'); @@ -48,16 +40,16 @@ export default function AuditLogsHub({ logs, search, category }) { }; return ( - - + + -
+
- {/* Header text */} -
+ {/* Simplified Header */} +
-

Centralized Compliance & Security Audit Logs

-

Track every critical administrative event, auto-OCR verification, stripe checkout charge, and user moderation updates.

+

System Logs

+

Detailed history of system events.

@@ -65,17 +57,17 @@ export default function AuditLogsHub({ logs, search, category }) {
- + setSearchTerm(e.target.value)} />
-
@@ -84,10 +76,10 @@ export default function AuditLogsHub({ logs, search, category }) { {[ { label: 'All Logs', val: 'all' }, { label: 'Admin Actions', val: 'admin_action' }, - { label: 'OCR Checks', val: 'verification' }, + { label: 'Verifications', val: 'verification' }, { label: 'Payments', val: 'payment' }, { label: 'Disputes', val: 'dispute' }, - { label: 'User Logins', val: 'user_activity' } + { label: 'Logins', val: 'user_activity' } ].map((cat) => (
+
+
+ + {/* Premium Full Detailed Investigation Workspace Sidebar Drawer */} + {isWorkspaceOpen && selectedTicket && ( +
+
+ + {/* Drawer Header */} +
+
+ +
+

+ Dispute Resolution Case: + {selectedTicket.id} +

+

Assigned Mediator: {selectedTicket.assigned_to}

+
+
+ +
+ + {/* Drawer Body Scroll Container */} +
+ + {/* Case Header Details Banner */} +
+
+ Parties + + {selectedTicket.party_one_name} ({selectedTicket.party_one_role}) + + + vs {selectedTicket.party_two_name} ({selectedTicket.party_two_role}) + +
+ +
+ Dispute Type + {selectedTicket.type} + {selectedTicket.reason} +
+ +
+ Case Status + + Status: {selectedTicket.status} + +
+ +
+ Created Date + {selectedTicket.created_at} + Case active since last update +
+
+ + {/* Dual Panel Workspace */} +
- {/* Chat Log Audit */} -
-
-

- + {/* Chat Log Transcript */} +
+
+

+ Direct Chat logs Transcript

-

Official authenticated conversation audit log

+

Official authenticated conversation history audits

- {/* Conversation thread */} -
+ {/* Scroll thread */} +
{selectedTicket.chat_logs.map((log, i) => { - const isEmployer = log.sender === 'Employer'; + const isMediator = log.sender === 'Mediator (Admin)'; + const isPartyTwo = log.sender === selectedTicket.party_two_name; return (
{log.sender} • {log.time} @@ -135,101 +417,80 @@ export default function DisputesHub({ tickets }) { })}
-
+
Logs Digitally Secured by UAE Vetting Compliance
- {/* Evidence & Resolution Tools */} + {/* Tools panel */}
+ {/* Evidence Files */} -
-

- - Evidence Files +
+

+ + Case Evidence Files

- {selectedTicket.evidence?.length > 0 ? ( -
- {selectedTicket.evidence.map((file, i) => ( -
+
+ {selectedTicket.evidence?.map((file, i) => ( +
+
+ {file.name} ({file.type}) - - View File - -
- ))} -
- ) : ( -

No attachment proofs uploaded.

- )} + + View + + +
+ ))} +
- {/* Admin Notes */} -
-

Internal Compliance Notes

-

- {selectedTicket.admin_notes} -

-
- +
+

+ + Mediator Reply & Actions +

+

Send a message to both parties or resolve the dispute.

+
+ + +