Merge branch 'mohan' of https://git.iproat.com/mohanmd/migrant-web into pavithra

This commit is contained in:
pavithrak27 2026-06-01 15:27:19 +05:30
commit 1912b8d971
26 changed files with 3152 additions and 1171 deletions

View File

@ -14,48 +14,23 @@ class AdminExtraController extends Controller
*/ */
public function safety() public function safety()
{ {
$reports = [ $reports = DB::table('moderation_reports')->get()->map(function ($report) {
[ return [
'id' => 'REP-101', 'id' => $report->id,
'reporter' => 'Marina Cleaners (Employer)', 'type' => $report->type,
'reported_user' => 'Grace Omondi (Worker)', 'reported_user_name' => $report->reported_user_name,
'reason' => 'Profile details mismatch / False advertising', 'reported_user_role' => $report->reported_user_role,
'description' => 'The worker claims to have 4 years experience in childcare, but when interviewed, they stated they only did housekeeping.', 'reported_user_avatar' => $report->reported_user_avatar,
'status' => 'Pending Review', 'reported_by_name' => $report->reported_by_name,
'severity' => 'Medium', 'reported_by_role' => $report->reported_by_role,
'created_at' => '2026-05-22 14:30', 'reported_by_avatar' => $report->reported_by_avatar,
], 'reason' => $report->reason,
[ 'priority' => $report->priority,
'id' => 'REP-102', 'status' => $report->status,
'reporter' => 'Siti Aminah (Worker)', 'description' => $report->description,
'reported_user' => 'Golden Hospitality (Employer)', 'reported_at' => date('M d, Y h:i A', strtotime($report->reported_at)),
'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',
]
]; ];
});
$rules = [ $rules = [
['id' => 1, 'name' => 'IP-Range Duplication Alert', 'trigger' => 'More than 3 worker accounts from the same IP', 'status' => 'Active'], ['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' '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)); 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() public function disputes()
{ {
$tickets = [ $tickets = DB::table('disputes')->get()->map(function ($dispute) {
[ // Decode or initialize logs dynamically
'id' => 'DISP-701', $logs = [];
'employer' => 'Marina Cleaners', if ($dispute->chat_logs) {
'worker' => 'Grace Omondi', $logs = json_decode($dispute->chat_logs, true) ?: [];
'subject' => 'Advance Payment Refund Dispute', } else {
'amount_aed' => 499, $logs = [
'status' => 'Open', ['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."],
'created_at' => '2026-05-20', ['sender' => $dispute->party_one_name, 'time' => '10:12 AM', 'message' => 'I have already explained my side. Please check the contract agreement details.'],
'chat_logs' => [ ['sender' => $dispute->party_two_name, 'time' => '10:14 AM', 'message' => 'That is not what we agreed upon. Let the admin mediator review the records.']
['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.'],
],
'evidence' => [
['type' => 'Receipt', 'name' => 'payment_slip.pdf', 'url' => '#'],
['type' => 'Agreement', 'name' => 'hiring_agreement_signed.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.'
]
]; ];
// 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' => 'Contract', 'name' => 'hiring_agreement_signed.pdf', 'url' => '#'],
['type' => 'ID Proof', 'name' => 'emirates_id_verification.pdf', 'url' => '#']
],
'admin_notes' => $dispute->admin_notes ?: 'Awaiting confirmation of contract terms under UAE law.'
];
});
return Inertia::render('Admin/Disputes/Index', [ return Inertia::render('Admin/Disputes/Index', [
'tickets' => $tickets 'tickets' => $tickets
@ -179,6 +156,49 @@ public function resolveDispute(Request $request, $id)
'action_taken' => 'required|string', '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)); 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' '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 * Notifications & Campaign Management
*/ */
@ -291,51 +356,68 @@ public function refundPayment(Request $request, $id)
} }
/** /**
* Interactive Reports & Analytics Hub * Interactive Reports Hub
*/ */
public function analytics() public function analytics()
{ {
// Supply rich visual charts metadata // 1. Worker Stats
$userGrowth = [ $workerStats = [
['month' => 'Dec 25', 'workers' => 850, 'employers' => 210], 'total' => DB::table('workers')->count(),
['month' => 'Jan 26', 'workers' => 990, 'employers' => 240], 'verified' => DB::table('workers')->where('verified', 1)->count(),
['month' => 'Feb 26', 'workers' => 1100, 'employers' => 280], 'active' => DB::table('workers')->where('status', 'active')->count(),
['month' => 'Mar 26', 'workers' => 1250, 'employers' => 310], 'inactive' => DB::table('workers')->where('status', 'inactive')->count(),
['month' => 'Apr 26', 'workers' => 1350, 'employers' => 345],
['month' => 'May 26', 'workers' => 1420, 'employers' => 380]
]; ];
$revenueBreakdown = [ // 2. Sponsor Stats
['month' => 'Dec 25', 'basic' => 15000, 'premium' => 18000, 'vip' => 12000], $sponsorStats = [
['month' => 'Jan 26', 'basic' => 16500, 'premium' => 19500, 'vip' => 13500], 'total' => DB::table('sponsors')->count(),
['month' => 'Feb 26', 'basic' => 18000, 'premium' => 22000, 'vip' => 15000], 'verified' => DB::table('sponsors')->where('is_verified', 1)->count(),
['month' => 'Mar 26', 'basic' => 19200, 'premium' => 25000, 'vip' => 18500], 'active_subscribers' => DB::table('sponsors')->where('subscription_status', 'active')->count(),
['month' => 'Apr 26', 'basic' => 21000, 'premium' => 29000, 'vip' => 22000], 'unpaid' => DB::table('sponsors')->where('payment_status', 'unpaid')->count(),
['month' => 'May 26', 'basic' => 22500, 'premium' => 32000, 'vip' => 24000]
]; ];
$retentionRates = [ // 3. Dispute & Safety Stats
['cohort' => 'Month 1', 'rate' => 92], $disputeStats = [
['cohort' => 'Month 2', 'rate' => 84], 'total' => DB::table('disputes')->count(),
['cohort' => 'Month 3', 'rate' => 79], 'open' => DB::table('disputes')->where('status', 'Open')->count(),
['cohort' => 'Month 4', 'rate' => 74], 'under_review' => DB::table('disputes')->where('status', 'Under Review')->count(),
['cohort' => 'Month 5', 'rate' => 71], 'resolved' => DB::table('disputes')->where('status', 'Resolved')->count(),
['cohort' => 'Month 6', 'rate' => 68],
]; ];
$hiringFunnel = [ $safetyStats = [
['stage' => 'Profiles Browsed', 'count' => 12500], 'total' => DB::table('moderation_reports')->count(),
['stage' => 'Chats Initiated', 'count' => 4820], 'pending' => DB::table('moderation_reports')->where('status', 'Pending')->count(),
['stage' => 'Interviews Held', 'count' => 1860], 'resolved' => DB::table('moderation_reports')->where('status', 'Resolved')->count(),
['stage' => 'Offers Extended', 'count' => 920],
['stage' => 'Workers Hired', 'count' => 610],
]; ];
// 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', [ return Inertia::render('Admin/Analytics/Index', [
'user_growth' => $userGrowth, 'worker_stats' => $workerStats,
'revenue_breakdown' => $revenueBreakdown, 'sponsor_stats' => $sponsorStats,
'retention_rates' => $retentionRates, 'dispute_stats' => $disputeStats,
'hiring_funnel' => $hiringFunnel 'safety_stats' => $safetyStats,
'payments' => $payments,
'total_revenue' => (float)$totalRevenue,
]); ]);
} }
@ -347,98 +429,36 @@ public function auditLogs(Request $request)
$searchTerm = $request->input('search', ''); $searchTerm = $request->input('search', '');
$category = $request->input('category', 'all'); $category = $request->input('category', 'all');
$allLogs = [ $query = DB::table('audit_logs');
// 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',
]
];
// Filter search & category if ($category !== 'all') {
$filtered = array_filter($allLogs, function ($log) use ($searchTerm, $category) { $query->where('category', $category);
$catMatches = $category === 'all' || $log['category'] === $category; }
$searchMatches = empty($searchTerm) ||
stripos($log['user'], $searchTerm) !== false || if (!empty($searchTerm)) {
stripos($log['action'], $searchTerm) !== false || $query->where(function($q) use ($searchTerm) {
stripos($log['id'], $searchTerm) !== false; $q->where('user', 'like', '%' . $searchTerm . '%')
return $catMatches && $searchMatches; ->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', [ return Inertia::render('Admin/AuditLogs/Index', [
'logs' => array_values($filtered), 'logs' => $logs,
'search' => $searchTerm, 'search' => $searchTerm,
'category' => $category, 'category' => $category,
]); ]);

View File

@ -7,12 +7,69 @@
use App\Models\Message; use App\Models\Message;
use App\Models\Worker; use App\Models\Worker;
use App\Models\User; use App\Models\User;
use App\Models\JobOffer;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
class EmployerMessageController extends Controller 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. * Get all conversations for the authorized employer.
* *
@ -186,6 +243,32 @@ public function sendMessage(Request $request, $id)
// 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?"
$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([ return response()->json([

View File

@ -0,0 +1,144 @@
<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\User;
use App\Models\Worker;
use App\Models\Review;
use Illuminate\Support\Facades\Validator;
class EmployerReviewController extends Controller
{
/**
* Add a new review for a worker.
* POST /api/employers/reviews
*/
public function addReview(Request $request)
{
/** @var User $employer */
$employer = $request->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);
}
}
}

View File

@ -11,6 +11,8 @@
use App\Models\JobOffer; use App\Models\JobOffer;
use App\Models\JobApplication; use App\Models\JobApplication;
use App\Models\JobPost; use App\Models\JobPost;
use App\Models\Review;
use App\Models\ProfileView;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
@ -356,4 +358,173 @@ public function hireCandidate(Request $request, $id = null)
], 500); ], 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);
}
}
} }

View File

@ -6,12 +6,69 @@
use App\Models\Conversation; use App\Models\Conversation;
use App\Models\Message; use App\Models\Message;
use App\Models\Worker; use App\Models\Worker;
use App\Models\JobOffer;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Validator; use Illuminate\Support\Facades\Validator;
class WorkerMessageController extends Controller 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. * Get all conversations for the authorized worker.
* *
@ -181,6 +238,9 @@ public function sendMessage(Request $request, $id)
// 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
self::processWorkerResponse($conv, $worker, $request->text);
}); });
return response()->json([ return response()->json([

View File

@ -6,6 +6,9 @@
use App\Models\JobOffer; use App\Models\JobOffer;
use App\Models\Worker; use App\Models\Worker;
use App\Models\WorkerDocument; use App\Models\WorkerDocument;
use App\Models\ProfileView;
use App\Models\EmployerProfile;
use App\Models\Announcement;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Storage;
@ -431,4 +434,140 @@ public function respondToOffer(Request $request, $id)
], 500); ], 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);
}
}
} }

View File

@ -9,6 +9,7 @@
use App\Models\Conversation; use App\Models\Conversation;
use App\Models\Message; use App\Models\Message;
use App\Models\Worker; use App\Models\Worker;
use App\Models\JobOffer;
class MessageController extends Controller class MessageController extends Controller
{ {
@ -36,6 +37,62 @@ private function resolveCurrentUser()
return null; 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) public function index(Request $request)
{ {
$user = $this->resolveCurrentUser(); $user = $this->resolveCurrentUser();
@ -150,6 +207,32 @@ public function send(Request $request, $id)
// 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?"
$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(); return back();
} }

View File

@ -10,6 +10,8 @@
use App\Models\WorkerCategory; use App\Models\WorkerCategory;
use App\Models\Shortlist; use App\Models\Shortlist;
use App\Models\JobOffer; use App\Models\JobOffer;
use App\Models\Review;
use App\Models\ProfileView;
class WorkerController extends Controller class WorkerController extends Controller
{ {
@ -135,6 +137,15 @@ public function show($id)
{ {
$w = Worker::with(['category', 'skills', 'documents'])->findOrFail($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'] ); $langs = ($w->id % 2 === 0) ? ['English', 'Arabic'] : ( ($w->id % 3 === 0) ? ['English', 'Tagalog'] : ['English', 'Hindi', 'Arabic'] );
$jobTypes = ['full-time', 'part-time', 'live-in', 'live-out']; $jobTypes = ['full-time', 'part-time', 'live-in', 'live-out'];
@ -158,9 +169,24 @@ public function show($id)
]; ];
$photo = $photos[$w->id % 4]; $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; $rating = 4.0 + (($w->id * 3) % 10) / 10.0;
$reviewsCount = ($w->id * 4) % 20 + 2; $reviewsCount = ($w->id * 4) % 20 + 2;
$reviews = [ $reviews = [
[ [
'id' => 1, 'id' => 1,
@ -177,6 +203,7 @@ public function show($id)
'comment' => 'Very punctual, did exactly what was expected. Highly recommend.', 'comment' => 'Very punctual, did exactly what was expected. Highly recommend.',
] ]
]; ];
}
$simDb = Worker::with('category') $simDb = Worker::with('category')
->where('id', '!=', $w->id) ->where('id', '!=', $w->id)

View File

@ -0,0 +1,26 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class ProfileView extends Model
{
use HasFactory;
protected $fillable = [
'employer_id',
'worker_id',
];
public function employer()
{
return $this->belongsTo(User::class, 'employer_id');
}
public function worker()
{
return $this->belongsTo(Worker::class, 'worker_id');
}
}

28
app/Models/Review.php Normal file
View File

@ -0,0 +1,28 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Review extends Model
{
use HasFactory;
protected $fillable = [
'employer_id',
'worker_id',
'rating',
'comment',
];
public function employer()
{
return $this->belongsTo(User::class, 'employer_id');
}
public function worker()
{
return $this->belongsTo(Worker::class, 'worker_id');
}
}

View File

@ -60,4 +60,14 @@ public function offers()
{ {
return $this->hasMany(JobOffer::class, 'worker_id'); return $this->hasMany(JobOffer::class, 'worker_id');
} }
public function reviews()
{
return $this->hasMany(Review::class);
}
public function profileViews()
{
return $this->hasMany(ProfileView::class);
}
} }

View File

@ -0,0 +1,193 @@
<?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
{
// 1. Create moderation_reports table
Schema::create('moderation_reports', function (Blueprint $table) {
$table->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');
}
};

View File

@ -0,0 +1,37 @@
<?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('disputes', function (Blueprint $table) {
if (!Schema::hasColumn('disputes', 'admin_notes')) {
$table->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');
});
}
};

View File

@ -0,0 +1,125 @@
<?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('audit_logs', function (Blueprint $table) {
$table->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');
}
};

View File

@ -0,0 +1,34 @@
<?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::create('reviews', function (Blueprint $table) {
$table->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');
}
};

View File

@ -0,0 +1,32 @@
<?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::create('profile_views', function (Blueprint $table) {
$table->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');
}
};

View File

@ -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": { "/employers/candidates": {
"get": { "get": {
"tags": [ "tags": [
"Employer/Pipeline" "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": { "components": {

View File

@ -2,321 +2,288 @@ import React, { useState } from 'react';
import { Head } from '@inertiajs/react'; import { Head } from '@inertiajs/react';
import AdminLayout from '@/Layouts/AdminLayout'; import AdminLayout from '@/Layouts/AdminLayout';
import { import {
BarChart3, FileText,
TrendingUp,
Users, Users,
ShieldAlert,
CreditCard, CreditCard,
Percent, Search,
Award, RefreshCw,
Sparkles, Download,
Calendar, Building2,
Download DollarSign
} from 'lucide-react'; } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
export default function AnalyticsHub({ user_growth, revenue_breakdown, retention_rates, hiring_funnel }) { export default function AnalyticsHub({
const [selectedTab, setSelectedTab] = useState('Growth'); 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) // Filter payments dynamically
const getCoordinatesForArea = (key, maxVal) => { const filteredPayments = payments.filter(payment => {
const points = []; const matchesSearch =
const paddingLeft = 40; payment.id.toString().toLowerCase().includes(searchTerm.toLowerCase()) ||
const paddingRight = 15; payment.user_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
const paddingTop = 20; payment.user_email.toLowerCase().includes(searchTerm.toLowerCase()) ||
const paddingBottom = 25; payment.description.toLowerCase().includes(searchTerm.toLowerCase());
const graphHeight = 200 - paddingTop - paddingBottom; const matchesStatus = statusFilter === 'all' || payment.status.toLowerCase() === statusFilter.toLowerCase();
const graphWidth = 450 - paddingLeft - paddingRight;
user_growth.forEach((d, idx) => { return matchesSearch && matchesStatus;
const val = d[key];
const x = paddingLeft + (idx / (user_growth.length - 1)) * graphWidth;
const y = paddingTop + graphHeight - (val / maxVal) * graphHeight;
points.push(`${x},${y}`);
}); });
// Add bottom-right and bottom-left to close the area shape const handleExportCSV = () => {
const startX = paddingLeft; const headers = ['Payment ID,Customer,Email,Details,Amount,Status,Date\n'];
const endX = paddingLeft + graphWidth; const rows = filteredPayments.map(p =>
const bottomY = paddingTop + graphHeight; `"${p.id}","${p.user_name}","${p.user_email}","${p.description}",${p.amount},"${p.status}","${p.created_at}"`
);
return `${startX},${bottomY} ${points.join(' ')} ${endX},${bottomY}`; 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);
const getLineCoordinates = (key, maxVal) => { link.setAttribute('download', `financial_report_${new Date().toISOString().split('T')[0]}.csv`);
const points = []; document.body.appendChild(link);
const paddingLeft = 40; link.click();
const paddingRight = 15; document.body.removeChild(link);
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(' ');
}; };
return ( return (
<AdminLayout title="Reports & Analytics Hub"> <AdminLayout title="Reports">
<Head title="System Analytics" /> <Head title="Reports" />
<div className="font-sans max-w-7xl mx-auto space-y-8"> <div className="font-sans max-w-7xl mx-auto space-y-8 pb-12">
{/* Title and date picker */} {/* Simplified Header */}
<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 border-b border-slate-200 pb-5">
<div> <div>
<h1 className="text-xl font-bold text-gray-900 tracking-tight">Interactive Platform Reports & Analytics Hub</h1> <h1 className="text-xl font-black text-slate-800 uppercase tracking-tight">Platform Reports</h1>
<p className="text-xs text-gray-500 mt-0.5">Explore subscription cohort retention, job conversions ratios, availability trends, and monthly billing splits.</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-3"> <div className="flex items-center gap-2">
<div className="flex bg-slate-200/60 p-1 rounded-xl w-fit border border-slate-200">
{['Growth', 'Revenue', 'Funnel'].map((tab) => (
<button <button
key={tab} onClick={() => window.location.reload()}
type="button" className="inline-flex items-center px-3.5 py-2 bg-white border border-slate-200 text-slate-700 rounded-xl text-xs font-black uppercase tracking-wider hover:bg-slate-50 transition-colors shadow-sm gap-1.5"
onClick={() => setSelectedTab(tab)}
className={`px-4 py-1.5 rounded-lg text-[10px] font-black uppercase tracking-wider transition-all ${
selectedTab === tab
? 'bg-white text-[#0F6E56] shadow-sm'
: 'text-slate-600 hover:text-slate-900'
}`}
> >
{tab} Report <RefreshCw className="w-3.5 h-3.5" />
<span>Refresh</span>
</button> </button>
))} <button
</div> onClick={handleExportCSV}
<button className="inline-flex items-center px-4 py-2.5 bg-slate-950 text-white rounded-xl text-xs font-bold hover:bg-slate-900 transition-colors shadow-sm space-x-2 uppercase tracking-wider"> className="inline-flex items-center px-4 py-2 bg-[#0F6E56] hover:bg-[#085041] text-white rounded-xl text-xs font-black uppercase tracking-wider transition-all shadow-md gap-1.5"
<Download className="w-4 h-4" /> >
<span>Export PDF</span> <Download className="w-3.5 h-3.5" />
<span>Export CSV</span>
</button> </button>
</div> </div>
</div> </div>
{/* Analytical Charts grid depending on tabs */} {/* Simplified Summary Cards */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{/* Growth cohort Report */} {/* Workers Card */}
{selectedTab === 'Growth' && ( <div className="bg-white border border-slate-200 rounded-2xl p-5 shadow-sm space-y-4 hover:border-[#0F6E56]/30 transition-all">
<div className="bg-white border border-slate-200 rounded-3xl p-6 shadow-sm space-y-6"> <div className="flex justify-between items-start">
<div className="p-2.5 bg-teal-50 rounded-xl text-[#0F6E56]">
<Users className="w-5 h-5" />
</div>
<span className="text-[10px] bg-slate-105 text-[#0F6E56] font-extrabold uppercase px-2 py-0.5 rounded tracking-wide">
Workers
</span>
</div>
<div> <div>
<h3 className="text-sm font-black text-slate-800 uppercase tracking-widest flex items-center gap-1.5"> <span className="text-[10px] text-slate-400 font-bold uppercase tracking-wider block">Total Workers</span>
<TrendingUp className="w-4.5 h-4.5 text-[#0F6E56]" /> <span className="text-3xl font-black text-slate-800 block mt-1 tracking-tight">{worker_stats.total}</span>
<span>User Cohort growth curves</span>
</h3>
<p className="text-[10px] text-slate-400 mt-0.5 uppercase tracking-wider font-bold">Employer and Worker registry trends (6 Months)</p>
</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="relative">
<svg className="w-full h-56" viewBox="0 0 450 200">
{/* Grids */}
<line x1="40" y1="20" x2="435" y2="20" stroke="#f8fafc" strokeWidth="1.5" />
<line x1="40" y1="75" x2="435" y2="75" stroke="#f8fafc" strokeWidth="1.5" />
<line x1="40" y1="130" x2="435" y2="130" stroke="#f8fafc" strokeWidth="1.5" />
<line x1="40" y1="180" x2="435" y2="180" stroke="#cbd5e1" strokeWidth="1.5" />
{/* Worker Area fill */}
<polygon
fill="url(#workerGrad)"
points={getCoordinatesForArea('workers', 1500)}
className="opacity-45"
/>
{/* Worker Line */}
<polyline
fill="none"
stroke="#10b981"
strokeWidth="4"
points={getLineCoordinates('workers', 1500)}
/>
{/* Employer Line */}
<polyline
fill="none"
stroke="#3b82f6"
strokeWidth="3.5"
strokeDasharray="4 4"
points={getLineCoordinates('employers', 1500)}
/>
{/* Gradients */}
<defs>
<linearGradient id="workerGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#10b981" />
<stop offset="100%" stopColor="#ffffff" />
</linearGradient>
</defs>
{/* X-Axis Month labels */}
{user_growth.map((d, i) => {
const x = 40 + (i / 5) * 380;
return (
<text key={i} x={x} y="195" textAnchor="middle" className="text-[9px] font-bold fill-slate-400 font-mono">
{d.month}
</text>
);
})}
{/* Y Labels */}
<text x="30" y="24" textAnchor="end" className="text-[9px] font-black fill-slate-300">1.5K</text>
<text x="30" y="80" textAnchor="end" className="text-[9px] font-black fill-slate-300">750</text>
<text x="30" y="135" textAnchor="end" className="text-[9px] font-black fill-slate-300">300</text>
<text x="30" y="185" textAnchor="end" className="text-[9px] font-black fill-slate-300">0</text>
</svg>
<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="w-3.5 h-3.5 bg-emerald-500 rounded" />
<span>Workers ({user_growth[5].workers})</span>
</div>
<div className="flex items-center space-x-2 text-xs font-bold text-slate-600">
<div className="w-3.5 h-1 border-t-2 border-dashed border-blue-500" />
<span>Employers ({user_growth[5].employers})</span>
</div>
</div>
</div>
</div>
)}
{/* Revenue reports */}
{selectedTab === 'Revenue' && (
<div className="bg-white border border-slate-200 rounded-3xl p-6 shadow-sm space-y-6">
<div> <div>
<h3 className="text-sm font-black text-slate-800 uppercase tracking-widest flex items-center gap-1.5"> <span className="text-slate-400 uppercase tracking-widest text-[8px] block">Verified</span>
<span className="text-slate-700 font-extrabold text-xs">{worker_stats.verified}</span>
</div>
<div>
<span className="text-slate-400 uppercase tracking-widest text-[8px] block">Active</span>
<span className="text-slate-700 font-extrabold text-xs">{worker_stats.active}</span>
</div>
</div>
</div>
{/* Sponsors Card */}
<div className="bg-white border border-slate-200 rounded-2xl p-5 shadow-sm space-y-4 hover:border-[#0F6E56]/30 transition-all">
<div className="flex justify-between items-start">
<div className="p-2.5 bg-blue-50 rounded-xl text-blue-600">
<Building2 className="w-5 h-5" />
</div>
<span className="text-[10px] bg-blue-50 text-blue-700 font-extrabold uppercase px-2 py-0.5 rounded tracking-wide">
Sponsors
</span>
</div>
<div>
<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>
</div>
<div className="pt-2 border-t border-slate-100 grid grid-cols-2 gap-2 text-[10px] font-bold text-slate-500">
<div>
<span className="text-slate-400 uppercase tracking-widest text-[8px] block">Active Plan</span>
<span className="text-slate-700 font-extrabold text-xs">{sponsor_stats.active_subscribers}</span>
</div>
<div>
<span className="text-slate-400 uppercase tracking-widest text-[8px] block">Unpaid</span>
<span className="text-slate-700 font-extrabold text-xs">{sponsor_stats.unpaid}</span>
</div>
</div>
</div>
{/* Disputes Card */}
<div className="bg-white border border-slate-200 rounded-2xl p-5 shadow-sm space-y-4 hover:border-[#0F6E56]/30 transition-all">
<div className="flex justify-between items-start">
<div className="p-2.5 bg-orange-50 rounded-xl text-orange-600">
<ShieldAlert className="w-5 h-5" />
</div>
<span className="text-[10px] bg-orange-50 text-orange-700 font-extrabold uppercase px-2 py-0.5 rounded tracking-wide">
Disputes
</span>
</div>
<div>
<span className="text-[10px] text-slate-400 font-bold uppercase tracking-wider block">Total Disputes</span>
<span className="text-3xl font-black text-slate-800 block mt-1 tracking-tight">{dispute_stats.open + dispute_stats.under_review}</span>
</div>
<div className="pt-2 border-t border-slate-100 grid grid-cols-2 gap-2 text-[10px] font-bold text-slate-500">
<div>
<span className="text-slate-400 uppercase tracking-widest text-[8px] block">Resolved</span>
<span className="text-slate-700 font-extrabold text-xs">{dispute_stats.resolved}</span>
</div>
<div>
<span className="text-slate-400 uppercase tracking-widest text-[8px] block">Pending Flags</span>
<span className="text-slate-700 font-extrabold text-xs">{safety_stats.pending}</span>
</div>
</div>
</div>
{/* Revenue Card */}
<div className="bg-white border border-slate-200 rounded-2xl p-5 shadow-sm space-y-4 hover:border-[#0F6E56]/30 transition-all">
<div className="flex justify-between items-start">
<div className="p-2.5 bg-emerald-50 rounded-xl text-emerald-600">
<DollarSign className="w-5 h-5" />
</div>
<span className="text-[10px] bg-emerald-50 text-emerald-700 font-extrabold uppercase px-2 py-0.5 rounded tracking-wide">
Revenue
</span>
</div>
<div>
<span className="text-[10px] text-slate-400 font-bold uppercase tracking-wider block">Total Revenue</span>
<span className="text-3xl font-black text-slate-800 block mt-1 tracking-tight">
AED {total_revenue.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</span>
</div>
<div className="pt-2 border-t border-slate-100 grid grid-cols-2 gap-2 text-[10px] font-bold text-slate-500">
<div>
<span className="text-slate-400 uppercase tracking-widest text-[8px] block">Total Paid</span>
<span className="text-slate-700 font-extrabold text-xs">{payments.length}</span>
</div>
<div>
<span className="text-slate-400 uppercase tracking-widest text-[8px] block">Average Amount</span>
<span className="text-slate-700 font-extrabold text-xs">
AED {payments.length > 0 ? (total_revenue / payments.length).toFixed(0) : '0'}
</span>
</div>
</div>
</div>
</div>
{/* Simplified Payment Ledger */}
<div className="bg-white border border-slate-200 rounded-3xl p-6 shadow-sm space-y-6">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 border-b border-slate-100 pb-5">
<div>
<h3 className="text-sm font-black text-slate-800 uppercase tracking-widest flex items-center gap-2">
<CreditCard className="w-4.5 h-4.5 text-[#0F6E56]" /> <CreditCard className="w-4.5 h-4.5 text-[#0F6E56]" />
<span>Stacked Subscription revenue splits</span> <span>Payment List</span>
</h3> </h3>
<p className="text-[10px] text-slate-400 mt-0.5 uppercase tracking-wider font-bold">Monthly billing splits (AED)</p> <p className="text-[10px] text-slate-400 font-semibold mt-0.5">Search and check payments.</p>
</div> </div>
{/* Search & Status Filters */}
<div className="flex flex-wrap items-center gap-3">
<div className="relative"> <div className="relative">
<svg className="w-full h-56" viewBox="0 0 450 200"> <Search className="w-4 h-4 text-slate-400 absolute left-3.5 top-1/2 -translate-y-1/2" />
<line x1="40" y1="180" x2="435" y2="180" stroke="#cbd5e1" strokeWidth="1.5" /> <input
type="text"
{/* Stacked bars for each month */} placeholder="Search payments..."
{revenue_breakdown.map((d, i) => { className="bg-slate-50 border border-slate-200 rounded-xl pl-9 pr-4 py-2 text-xs font-semibold focus:bg-white outline-none focus:ring-2 focus:ring-[#0F6E56]/10 w-64 shadow-sm"
const x = 50 + i * 62; value={searchTerm}
const total = d.basic + d.premium + d.vip; onChange={e => setSearchTerm(e.target.value)}
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 (
<g key={i} className="cursor-pointer group">
{/* Basic */}
<rect x={x} y={basicY} width="22" height={basicH} fill="#94a3b8" rx="2" />
{/* Premium */}
<rect x={x} y={premiumY} width="22" height={premiumH} fill="#3b82f6" />
{/* VIP */}
<rect x={x} y={vipY} width="22" height={vipH} fill="#10b981" rx="2" />
<text x={x + 11} y="195" textAnchor="middle" className="text-[9px] font-bold fill-slate-400 font-mono">
{d.month.split(' ')[0]}
</text>
</g>
);
})}
<text x="30" y="30" textAnchor="end" className="text-[9px] font-black fill-slate-300">80K</text>
<text x="30" y="105" textAnchor="end" className="text-[9px] font-black fill-slate-300">40K</text>
<text x="30" y="180" textAnchor="end" className="text-[9px] font-black fill-slate-300">0</text>
</svg>
<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="w-3 h-3 bg-slate-400 rounded-sm" />
<span>Basic</span>
</div>
<div className="flex items-center space-x-2 text-xs font-bold text-slate-600">
<div className="w-3 h-3 bg-blue-500 rounded-sm" />
<span>Premium</span>
</div>
<div className="flex items-center space-x-2 text-xs font-bold text-slate-600">
<div className="w-3 h-3 bg-emerald-500 rounded-sm" />
<span>VIP</span>
</div>
</div>
</div>
</div>
)}
{/* Funnel Conversions */}
{selectedTab === 'Funnel' && (
<div className="bg-white border border-slate-200 rounded-3xl p-6 shadow-sm space-y-6">
<div>
<h3 className="text-sm font-black text-slate-800 uppercase tracking-widest flex items-center gap-1.5">
<Percent className="w-4.5 h-4.5 text-[#0F6E56]" />
<span>Hiring Placement Funnel ratios</span>
</h3>
<p className="text-[10px] text-slate-400 mt-0.5 uppercase tracking-wider font-bold">Hiring Conversion stage details (This month)</p>
</div>
<div className="space-y-4">
{hiring_funnel.map((item, idx) => {
const maxVal = hiring_funnel[0].count;
const pct = ((item.count / maxVal) * 100).toFixed(1);
return (
<div key={idx} className="space-y-2 text-xs font-bold text-slate-700">
<div className="flex items-center justify-between">
<span>{item.stage}</span>
<span>{item.count.toLocaleString()} ({pct}%)</span>
</div>
<div className="w-full bg-slate-100 rounded-full h-3.5 overflow-hidden">
<div
className="h-full rounded-full bg-gradient-to-r from-teal-600 to-teal-500 transition-all duration-500"
style={{ width: `${pct}%` }}
/> />
</div> </div>
</div>
);
})}
</div>
</div>
)}
{/* Right side cohort Retention Analysis: Month-over-Month curves */} <select
<div className="bg-white border border-slate-200 rounded-3xl p-6 shadow-sm flex flex-col justify-between"> className="bg-slate-50 border border-slate-200 rounded-xl px-4 py-2 text-xs font-semibold outline-none cursor-pointer focus:bg-white focus:ring-2 focus:ring-[#0F6E56]/10 shadow-sm"
value={statusFilter}
onChange={e => setStatusFilter(e.target.value)}
>
<option value="all">All Payments</option>
<option value="success">Success</option>
<option value="pending">Pending</option>
<option value="failed">Failed</option>
</select>
</div>
</div>
{/* Payments Table */}
<div className="overflow-x-auto">
<table className="w-full text-left border-collapse">
<thead>
<tr className="border-b border-slate-150 text-[10px] text-slate-400 font-black uppercase tracking-widest">
<th className="px-5 py-3">Payment ID</th>
<th className="px-5 py-3">Customer</th>
<th className="px-5 py-3">Details</th>
<th className="px-5 py-3">Amount</th>
<th className="px-5 py-3">Status</th>
<th className="px-5 py-3">Date</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100 text-xs font-bold text-slate-700">
{filteredPayments.map(p => (
<tr key={p.id} className="hover:bg-slate-50/50 transition-colors">
<td className="px-5 py-4 font-mono text-[#0F6E56]">TXN-{p.id}</td>
<td className="px-5 py-4">
<div> <div>
<h3 className="text-sm font-black text-slate-800 uppercase tracking-widest flex items-center gap-1.5"> <span className="font-extrabold text-slate-800 block">{p.user_name}</span>
<Award className="w-4.5 h-4.5 text-[#0F6E56]" /> <span className="text-[10px] text-slate-400 font-semibold block">{p.user_email}</span>
<span>Subscription Cohort Retention rates</span>
</h3>
<p className="text-[10px] text-slate-400 mt-0.5 uppercase tracking-wider font-bold">Month 1 to Month 6 user retention metrics</p>
</div>
<div className="space-y-4 my-6">
{retention_rates.map((cohort, i) => (
<div key={i} className="flex items-center justify-between text-xs font-bold text-slate-700">
<span className="w-20">{cohort.cohort}</span>
<div className="flex-1 mx-4 bg-slate-100 rounded-full h-3 overflow-hidden">
<div
className="h-full bg-gradient-to-r from-blue-600 to-blue-500 rounded-full"
style={{ width: `${cohort.rate}%` }}
/>
</div>
<span className="text-blue-600 w-10 text-right">{cohort.rate}%</span>
</div> </div>
</td>
<td className="px-5 py-4 font-semibold text-slate-650">{p.description}</td>
<td className="px-5 py-4 font-extrabold text-slate-800">
{p.amount.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} {p.currency}
</td>
<td className="px-5 py-4">
<span className={`px-2 py-0.5 rounded-full text-[9px] font-black uppercase tracking-wide ${
p.status === 'success' ? 'bg-emerald-50 text-emerald-700 border border-emerald-100' :
p.status === 'pending' ? 'bg-amber-50 text-amber-700 border border-amber-100' :
'bg-red-50 text-red-700 border border-red-100'
}`}>
{p.status}
</span>
</td>
<td className="px-5 py-4 text-slate-400 font-semibold">{p.created_at}</td>
</tr>
))} ))}
{filteredPayments.length === 0 && (
<tr>
<td colSpan="6" className="p-12 text-center text-slate-400 font-bold uppercase">
No payments found.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div> </div>
<div className="bg-emerald-50/50 p-4 border border-emerald-100 rounded-2xl text-[11px] font-bold text-teal-800 leading-relaxed shadow-sm">
<Sparkles className="w-4 h-4 text-emerald-600 mb-1" />
<span>System Insights: Employer VIP and Premium tier subscribers yield a 79% retention rate beyond Month 3, validating platform organic vetting value.</span>
</div>
</div>
</div>
</div> </div>
</AdminLayout> </AdminLayout>
); );

View File

@ -3,17 +3,9 @@ import { Head, router } from '@inertiajs/react';
import AdminLayout from '@/Layouts/AdminLayout'; import AdminLayout from '@/Layouts/AdminLayout';
import { import {
Search, Search,
Filter,
History,
ShieldCheck,
User, User,
CreditCard,
Scale,
AlertTriangle,
Eye,
Globe, Globe,
Terminal, Terminal
ArrowRight
} from 'lucide-react'; } from 'lucide-react';
import { import {
Table, Table,
@ -25,7 +17,7 @@ import {
} from '@/components/ui/table'; } from '@/components/ui/table';
import { Badge } from '@/components/ui/badge'; 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 [searchTerm, setSearchTerm] = useState(search || '');
const [selectedCategory, setSelectedCategory] = useState(category || 'all'); const [selectedCategory, setSelectedCategory] = useState(category || 'all');
@ -48,16 +40,16 @@ export default function AuditLogsHub({ logs, search, category }) {
}; };
return ( return (
<AdminLayout title="System Compliance Audit Logs"> <AdminLayout title="Logs">
<Head title="System Audit Logs" /> <Head title="Logs" />
<div className="font-sans max-w-7xl mx-auto space-y-6"> <div className="font-sans max-w-7xl mx-auto space-y-6 pb-12">
{/* Header text */} {/* Simplified Header */}
<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 border-b border-slate-200 pb-5">
<div> <div>
<h1 className="text-xl font-bold text-gray-900 tracking-tight">Centralized Compliance & Security Audit Logs</h1> <h1 className="text-xl font-black text-slate-800 uppercase tracking-tight">System Logs</h1>
<p className="text-xs text-gray-500 mt-0.5">Track every critical administrative event, auto-OCR verification, stripe checkout charge, and user moderation updates.</p> <p className="text-xs text-slate-500 mt-0.5 font-medium">Detailed history of system events.</p>
</div> </div>
</div> </div>
@ -65,17 +57,17 @@ export default function AuditLogsHub({ logs, search, category }) {
<div className="bg-white p-4 rounded-2xl border border-slate-200 shadow-sm flex flex-col md:flex-row gap-4 items-center justify-between"> <div className="bg-white p-4 rounded-2xl border border-slate-200 shadow-sm flex flex-col md:flex-row gap-4 items-center justify-between">
<form onSubmit={handleSearchSubmit} className="flex flex-1 items-center space-x-3 w-full md:w-auto"> <form onSubmit={handleSearchSubmit} className="flex flex-1 items-center space-x-3 w-full md:w-auto">
<div className="relative flex-1 max-w-md"> <div className="relative flex-1 max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" /> <Search className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input <input
type="text" type="text"
placeholder="Search logs by IP, actor, message or ticket ID..." placeholder="Search logs..."
className="w-full pl-10 pr-4 py-2.5 bg-slate-50 border border-slate-100 rounded-xl text-sm font-medium focus:bg-white focus:ring-4 focus:ring-teal-500/10 outline-none transition-all" className="w-full pl-10 pr-4 py-2.5 bg-slate-50 border border-slate-100 rounded-xl text-xs font-semibold focus:bg-white focus:ring-2 focus:ring-[#0F6E56]/10 outline-none transition-all"
value={searchTerm} value={searchTerm}
onChange={e => setSearchTerm(e.target.value)} onChange={e => setSearchTerm(e.target.value)}
/> />
</div> </div>
<button type="submit" className="px-4 py-2.5 bg-slate-900 hover:bg-slate-800 text-white rounded-xl text-xs font-black uppercase tracking-wider"> <button type="submit" className="px-4 py-2.5 bg-[#0F6E56] hover:bg-[#085041] text-white rounded-xl text-xs font-black uppercase tracking-wider transition-colors">
Search Logs Search
</button> </button>
</form> </form>
@ -84,10 +76,10 @@ export default function AuditLogsHub({ logs, search, category }) {
{[ {[
{ label: 'All Logs', val: 'all' }, { label: 'All Logs', val: 'all' },
{ label: 'Admin Actions', val: 'admin_action' }, { label: 'Admin Actions', val: 'admin_action' },
{ label: 'OCR Checks', val: 'verification' }, { label: 'Verifications', val: 'verification' },
{ label: 'Payments', val: 'payment' }, { label: 'Payments', val: 'payment' },
{ label: 'Disputes', val: 'dispute' }, { label: 'Disputes', val: 'dispute' },
{ label: 'User Logins', val: 'user_activity' } { label: 'Logins', val: 'user_activity' }
].map((cat) => ( ].map((cat) => (
<button <button
key={cat.val} key={cat.val}
@ -108,18 +100,18 @@ export default function AuditLogsHub({ logs, search, category }) {
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden"> <div className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow className="bg-slate-50/50 hover:bg-slate-50/50"> <TableRow className="bg-slate-50/50 hover:bg-slate-50/50 border-b border-slate-150">
<TableHead className="font-bold text-slate-500 text-[10px] uppercase tracking-widest h-14 pl-8">Event ID / Category</TableHead> <TableHead className="font-bold text-slate-550 text-[10px] uppercase tracking-widest h-14 pl-8">Log ID & Category</TableHead>
<TableHead className="font-bold text-slate-500 text-[10px] uppercase tracking-widest h-14">Actor Account</TableHead> <TableHead className="font-bold text-slate-550 text-[10px] uppercase tracking-widest h-14">User</TableHead>
<TableHead className="font-bold text-slate-500 text-[10px] uppercase tracking-widest h-14">Activity Description</TableHead> <TableHead className="font-bold text-slate-550 text-[10px] uppercase tracking-widest h-14">Action</TableHead>
<TableHead className="font-bold text-slate-500 text-[10px] uppercase tracking-widest h-14">IP Address</TableHead> <TableHead className="font-bold text-slate-550 text-[10px] uppercase tracking-widest h-14">IP Address</TableHead>
<TableHead className="font-bold text-slate-500 text-[10px] uppercase tracking-widest h-14 text-right pr-8">Timestamp</TableHead> <TableHead className="font-bold text-slate-550 text-[10px] uppercase tracking-widest h-14 text-right pr-8">Time</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{logs && logs.length > 0 ? ( {logs && logs.length > 0 ? (
logs.map((log) => ( logs.map((log) => (
<TableRow key={log.id} className="hover:bg-slate-50/50 transition-colors"> <TableRow key={log.id} className="hover:bg-slate-50/50 transition-colors border-b border-slate-100">
<TableCell className="py-4 pl-8"> <TableCell className="py-4 pl-8">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Badge className={`border-none text-[8px] font-black uppercase tracking-wider ${ <Badge className={`border-none text-[8px] font-black uppercase tracking-wider ${
@ -137,8 +129,10 @@ export default function AuditLogsHub({ logs, search, category }) {
<span>{log.user}</span> <span>{log.user}</span>
</div> </div>
</TableCell> </TableCell>
<TableCell className="text-xs font-semibold text-slate-600 leading-normal max-w-sm"> <TableCell className="text-xs font-semibold text-slate-600 leading-normal">
<div className="max-w-[420px] break-words whitespace-normal py-1">
{log.action} {log.action}
</div>
</TableCell> </TableCell>
<TableCell className="text-xs font-mono font-medium text-slate-400"> <TableCell className="text-xs font-mono font-medium text-slate-400">
<div className="flex items-center space-x-1"> <div className="flex items-center space-x-1">
@ -146,14 +140,14 @@ export default function AuditLogsHub({ logs, search, category }) {
<span>{log.ip_address}</span> <span>{log.ip_address}</span>
</div> </div>
</TableCell> </TableCell>
<TableCell className="text-xs text-slate-400 text-right pr-8 font-mono">{log.timestamp}</TableCell> <TableCell className="text-xs text-slate-400 text-right pr-8 font-semibold">{log.timestamp}</TableCell>
</TableRow> </TableRow>
)) ))
) : ( ) : (
<TableRow> <TableRow>
<TableCell colSpan={5} className="py-20 text-center"> <TableCell colSpan={5} className="py-20 text-center">
<Terminal className="w-12 h-12 text-slate-200 mx-auto mb-3" /> <Terminal className="w-12 h-12 text-slate-200 mx-auto mb-3" />
<div className="font-bold text-slate-400">No security audit logs found matching your criteria.</div> <div className="font-bold text-slate-400 uppercase text-xs">No logs found.</div>
</TableCell> </TableCell>
</TableRow> </TableRow>
)} )}

View File

@ -13,119 +13,401 @@ import {
ArrowUpRight, ArrowUpRight,
User, User,
ShieldCheck, ShieldCheck,
Send Send,
Search,
ChevronDown,
MoreVertical,
Clock,
Eye,
Plus,
X,
Folder,
AlertTriangle,
Hourglass
} from 'lucide-react'; } from 'lucide-react';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
export default function DisputesHub({ tickets }) { export default function DisputesHub({ tickets }) {
const [selectedTicket, setSelectedTicket] = useState(tickets[0] || null); const [selectedTicket, setSelectedTicket] = useState(null);
const [isWorkspaceOpen, setIsWorkspaceOpen] = useState(false);
// Notes / Resolution states // Notes / Resolution states
const [internalNote, setInternalNote] = useState(''); const [internalNote, setInternalNote] = useState('');
const [resolutionAction, setResolutionAction] = useState('refund_employer'); const [resolutionAction, setResolutionAction] = useState('refund_employer');
const [resolutionSummary, setResolutionSummary] = useState(''); const [resolutionSummary, setResolutionSummary] = useState('');
// Filters
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState('All Status');
const [priorityFilter, setPriorityFilter] = useState('All Priority');
const [typeFilter, setTypeFilter] = useState('All Types');
const [sortBy, setSortBy] = useState('Newest First');
const handleAddNote = (e) => { const handleAddNote = (e) => {
e.preventDefault(); e.preventDefault();
if (!internalNote.trim()) return; if (!internalNote.trim() || !selectedTicket) return;
router.post(`/admin/disputes/${selectedTicket.id}/add-note`, { note: internalNote }, { router.post(`/admin/disputes/${selectedTicket.id}/add-note`, { note: internalNote }, {
onSuccess: () => { onSuccess: () => {
if (selectedTicket) {
selectedTicket.admin_notes += "\n• " + internalNote; selectedTicket.admin_notes += "\n• " + internalNote;
}
setInternalNote(''); setInternalNote('');
} }
}); });
}; };
const handleResolve = (e) => { const handleResolve = (e) => {
e.preventDefault(); if (e) e.preventDefault();
if (!selectedTicket) return;
router.post(`/admin/disputes/${selectedTicket.id}/resolve`, { router.post(`/admin/disputes/${selectedTicket.id}/resolve`, {
resolution: resolutionSummary, resolution: internalNote || 'Resolved by mediator',
action_taken: resolutionAction action_taken: 'close_ticket'
}, { }, {
onSuccess: () => { onSuccess: () => {
if (selectedTicket) {
selectedTicket.status = 'Resolved'; selectedTicket.status = 'Resolved';
} setIsWorkspaceOpen(false);
setSelectedTicket(null);
setInternalNote('');
} }
}); });
}; };
return ( // Filter tickets
<AdminLayout title="Disputes Management Hub"> const filteredTickets = (tickets || []).filter(ticket => {
<Head title="Disputes Management" /> // Status filter
if (statusFilter !== 'All Status' && ticket.status !== statusFilter) return false;
<div className="font-sans max-w-7xl mx-auto space-y-6"> // Priority filter
if (priorityFilter !== 'All Priority' && ticket.priority !== priorityFilter) return false;
// Type filter
if (typeFilter !== 'All Types' && ticket.type !== typeFilter) return false;
// Search query
if (searchQuery.trim() !== '') {
const query = searchQuery.toLowerCase();
return (
ticket.id.toLowerCase().includes(query) ||
ticket.party_one_name.toLowerCase().includes(query) ||
ticket.party_two_name.toLowerCase().includes(query) ||
ticket.reason.toLowerCase().includes(query)
);
}
return true;
});
return (
<AdminLayout title="Dispute Management">
<Head title="Dispute Management" />
<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> <div>
<h1 className="text-xl font-bold text-gray-900 tracking-tight">Contract & Payment Dispute Resolution Center</h1> <h1 className="text-2xl font-bold text-slate-900 tracking-tight">Dispute Management</h1>
<p className="text-xs text-gray-500 mt-0.5">Audit employer-worker chat history transcripts, review visa fee dispute receipts, and settle escrow refund transfers.</p> <p className="text-sm text-slate-500 mt-1">Handle complaints and resolve disputes between workers and sponsors.</p>
</div> </div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 items-start"> <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">
<Plus className="w-4 h-4" />
<span>New Dispute</span>
</button>
</div>
{/* Column 1: Disputes List */} {/* Upper Row Metric Cards */}
<div className="space-y-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div className="bg-white p-4 border border-slate-200 rounded-2xl shadow-sm">
<span className="text-[9px] font-black text-slate-400 uppercase tracking-widest block mb-3">Open Dispute Tickets</span> {/* Card 1: Total Disputes */}
<div className="space-y-2"> <div className="bg-white p-5 border border-slate-200 rounded-2xl shadow-sm flex items-center justify-between">
{tickets.map((ticket) => ( <div className="space-y-1">
<div <span className="text-xs text-slate-500 font-semibold block">Total Disputes</span>
key={ticket.id} <span className="text-2xl font-extrabold text-slate-900 block">36</span>
onClick={() => setSelectedTicket(ticket)} <span className="inline-flex items-center gap-0.5 px-2 py-0.5 bg-emerald-50 text-emerald-700 rounded-full text-[10px] font-bold">
className={`p-4 rounded-xl border cursor-pointer transition-all ${ 8% <span className="font-normal text-slate-400 ml-1">from last 30 days</span>
selectedTicket?.id === ticket.id </span>
? 'bg-emerald-50/50 border-[#0F6E56] shadow-sm' </div>
: 'bg-white border-slate-100 hover:bg-slate-50' <div className="p-3 bg-[#0F6E56]/10 text-[#0F6E56] rounded-2xl">
}`} <Scale className="w-6 h-6" />
</div>
</div>
{/* Card 2: Open */}
<div className="bg-white p-5 border border-slate-200 rounded-2xl shadow-sm flex items-center justify-between">
<div className="space-y-1">
<span className="text-xs text-slate-500 font-semibold block">Open</span>
<span className="text-2xl font-extrabold text-slate-900 block">12</span>
<span className="text-[11px] text-amber-600 font-bold block">Needs attention</span>
</div>
<div className="p-3 bg-amber-50 text-amber-600 rounded-2xl">
<Folder className="w-6 h-6" />
</div>
</div>
{/* Card 3: Under Review */}
<div className="bg-white p-5 border border-slate-200 rounded-2xl shadow-sm flex items-center justify-between">
<div className="space-y-1">
<span className="text-xs text-slate-500 font-semibold block">Under Review</span>
<span className="text-2xl font-extrabold text-slate-900 block">10</span>
<span className="text-[11px] text-blue-600 font-bold block">In progress</span>
</div>
<div className="p-3 bg-blue-50 text-blue-600 rounded-2xl">
<Eye className="w-6 h-6" />
</div>
</div>
{/* Card 4: Resolved */}
<div className="bg-white p-5 border border-slate-200 rounded-2xl shadow-sm flex items-center justify-between">
<div className="space-y-1">
<span className="text-xs text-slate-500 font-semibold block">Resolved</span>
<span className="text-2xl font-extrabold text-slate-900 block">8</span>
<span className="text-[11px] text-emerald-600 font-bold block">This period</span>
</div>
<div className="p-3 bg-emerald-50 text-emerald-600 rounded-2xl">
<CheckCircle className="w-6 h-6" />
</div>
</div>
</div>
{/* Table / Filters Section */}
<div className="bg-white border border-slate-200 rounded-2xl shadow-sm overflow-hidden">
{/* Filters Header */}
<div className="p-4 border-b border-slate-100 flex flex-col lg:flex-row lg:items-center justify-between gap-4 bg-slate-50/20">
{/* Left search */}
<div className="relative w-full lg:w-80">
<Search className="w-4 h-4 text-slate-400 absolute left-3.5 top-1/2 -translate-y-1/2" />
<input
type="text"
placeholder="Search disputes..."
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
className="bg-white border border-slate-200 pl-10 pr-4 py-2 rounded-xl text-xs font-semibold focus:outline-none focus:ring-2 focus:ring-[#0F6E56]/20 w-full shadow-sm"
/>
</div>
{/* Right filters */}
<div className="flex flex-wrap items-center gap-3">
<select
value={statusFilter}
onChange={e => setStatusFilter(e.target.value)}
className="bg-white border border-slate-200 text-slate-700 text-xs font-bold px-3 py-2 rounded-xl outline-none cursor-pointer hover:bg-slate-50 transition-colors shadow-sm"
> >
<div className="flex items-center justify-between mb-2"> <option value="All Status">All Status</option>
<span className="font-mono text-[9px] text-slate-400 font-bold">{ticket.id}</span> <option value="Open">Open</option>
<Badge className={`border-none text-[8px] font-black uppercase ${ <option value="Under Review">Under Review</option>
ticket.status === 'Open' ? 'bg-amber-100 text-amber-700' : <option value="Waiting Response">Waiting Response</option>
ticket.status === 'Resolved' ? 'bg-emerald-100 text-emerald-800' : 'bg-blue-100 text-blue-700' <option value="Resolved">Resolved</option>
}`}>{ticket.status}</Badge> </select>
</div>
<h4 className="text-xs font-black text-slate-900 tracking-tight leading-tight">{ticket.subject}</h4>
<div className="flex items-center justify-between mt-3 text-[10px] text-slate-400 font-bold">
<span>AED {ticket.amount_aed}</span> <select
<span>{ticket.created_at}</span> value={typeFilter}
</div> onChange={e => setTypeFilter(e.target.value)}
</div> className="bg-white border border-slate-200 text-slate-700 text-xs font-bold px-3 py-2 rounded-xl outline-none cursor-pointer hover:bg-slate-50 transition-colors shadow-sm"
))} >
</div> <option value="All Types">All Types</option>
<option value="Payment Issue">Payment Issue</option>
<option value="Behavior Issue">Behavior Issue</option>
<option value="Working Conditions">Working Conditions</option>
<option value="Abuse/Harassment">Abuse/Harassment</option>
</select>
<select
value={sortBy}
onChange={e => setSortBy(e.target.value)}
className="bg-white border border-slate-200 text-slate-700 text-xs font-bold px-3 py-2 rounded-xl outline-none cursor-pointer hover:bg-slate-50 transition-colors shadow-sm"
>
<option value="Newest First">Sort: Newest First</option>
<option value="Oldest First">Sort: Oldest First</option>
</select>
</div> </div>
</div> </div>
{/* Column 2: Audit Logs & Chat Logs */} {/* Table Row Content */}
<div className="lg:col-span-2 space-y-6"> <div className="overflow-x-auto">
{selectedTicket ? ( <table className="w-full text-left border-collapse text-xs font-semibold text-slate-600">
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> <thead>
<tr className="border-b border-slate-100 bg-slate-50/50 text-slate-400 font-bold uppercase tracking-wider text-[10px]">
{/* Chat Log Audit */} <th className="px-5 py-4">Dispute ID</th>
<div className="bg-white border border-slate-200 rounded-3xl p-5 shadow-sm flex flex-col justify-between h-[520px]"> <th className="px-5 py-4">Raised By</th>
<th className="px-5 py-4">Type</th>
<th className="px-5 py-4">Reason</th>
<th className="px-5 py-4">Status</th>
<th className="px-5 py-4">Created At</th>
<th className="px-5 py-4 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{filteredTickets.map((ticket) => (
<tr key={ticket.id} className="hover:bg-slate-50/40 transition-colors">
<td className="px-5 py-4 font-mono font-bold text-[#0F6E56] hover:underline cursor-pointer">
{ticket.id}
</td>
<td className="px-5 py-4">
<div className="flex items-center gap-3">
<img
src={ticket.party_one_avatar || 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&q=80&w=100'}
alt={ticket.party_one_name}
className="w-7 h-7 rounded-full object-cover border border-slate-200 shadow-sm"
/>
<div> <div>
<h3 className="text-xs font-black text-slate-800 uppercase tracking-widest flex items-center gap-1"> <span className="font-extrabold text-slate-800 block">
<MessageSquare className="w-4 h-4 text-[#0F6E56]" /> {ticket.party_one_name}
</span>
<span className="text-[10px] text-slate-400 font-bold block uppercase tracking-wider">
{ticket.party_one_role}
</span>
</div>
</div>
</td>
<td className="px-5 py-4">
<span className={`px-2.5 py-1 rounded-full text-[10px] font-black uppercase ${
ticket.type === 'Payment Issue' ? 'bg-blue-100 text-blue-700' :
ticket.type === 'Behavior Issue' ? 'bg-purple-100 text-purple-700' :
ticket.type === 'Working Conditions' ? 'bg-emerald-100 text-emerald-700' : 'bg-red-100 text-red-700'
}`}>
{ticket.type}
</span>
</td>
<td className="px-5 py-4 font-semibold text-slate-800">
{ticket.reason}
</td>
<td className="px-5 py-4">
<span className={`px-2 py-0.5 rounded-full text-[10px] font-black uppercase ${
ticket.status === 'Open' ? 'bg-orange-50 text-orange-700' :
ticket.status === 'Under Review' ? 'bg-blue-50 text-blue-700' :
ticket.status === 'Waiting Response' ? 'bg-purple-50 text-purple-700' : 'bg-emerald-50 text-emerald-700'
}`}>
{ticket.status}
</span>
</td>
<td className="px-5 py-4 text-slate-400 font-semibold">
{ticket.created_at}
</td>
<td className="px-5 py-4 text-right">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => { setSelectedTicket(ticket); setIsWorkspaceOpen(true); }}
className="px-3 py-1.5 bg-white border border-slate-200 text-slate-700 rounded-lg hover:bg-slate-50 transition-colors shadow-sm font-bold uppercase tracking-wider text-[10px]"
>
View
</button>
<button
onClick={() => { setSelectedTicket(ticket); setIsWorkspaceOpen(true); }}
className="px-3.5 py-1.5 bg-[#0F6E56] hover:bg-[#085041] text-white rounded-lg transition-colors font-black uppercase tracking-wider text-[10px]"
>
Manage
</button>
</div>
</td>
</tr>
))}
{filteredTickets.length === 0 && (
<tr>
<td colSpan="9" className="p-10 text-center font-bold text-slate-400 uppercase">
No active disputes found matching your criteria.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
</div>
{/* Premium Full Detailed Investigation Workspace Sidebar Drawer */}
{isWorkspaceOpen && selectedTicket && (
<div className="fixed inset-0 bg-slate-900/50 backdrop-blur-sm z-50 flex justify-end transition-opacity">
<div className="w-full max-w-5xl bg-slate-50 h-full shadow-2xl flex flex-col justify-between overflow-hidden animate-slide-in-right">
{/* Drawer Header */}
<div className="bg-white p-5 border-b border-slate-200 flex items-center justify-between">
<div className="flex items-center gap-3">
<Scale className="w-6 h-6 text-[#0F6E56]" />
<div>
<h2 className="text-base font-black text-slate-800 uppercase tracking-wide flex items-center gap-1.5">
<span>Dispute Resolution Case:</span>
<span className="font-mono text-[#0F6E56]">{selectedTicket.id}</span>
</h2>
<p className="text-xs text-slate-400 font-semibold mt-0.5">Assigned Mediator: {selectedTicket.assigned_to}</p>
</div>
</div>
<button
onClick={() => { setIsWorkspaceOpen(false); setSelectedTicket(null); }}
className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-full transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Drawer Body Scroll Container */}
<div className="flex-1 overflow-y-auto p-6 space-y-6">
{/* Case Header Details Banner */}
<div className="bg-white border border-slate-200 rounded-2xl p-5 shadow-sm grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="space-y-1">
<span className="text-[10px] text-slate-400 font-bold uppercase block">Parties</span>
<span className="font-extrabold text-slate-800 text-sm block">
{selectedTicket.party_one_name} <span className="font-normal text-slate-400">({selectedTicket.party_one_role})</span>
</span>
<span className="font-semibold text-slate-500 text-xs block">
vs {selectedTicket.party_two_name} <span className="font-normal text-slate-400">({selectedTicket.party_two_role})</span>
</span>
</div>
<div className="space-y-1">
<span className="text-[10px] text-slate-400 font-bold uppercase block">Dispute Type</span>
<span className="font-extrabold text-slate-800 block text-xs uppercase tracking-wide">{selectedTicket.type}</span>
<span className="text-[11px] text-slate-500 font-medium block leading-relaxed">{selectedTicket.reason}</span>
</div>
<div className="space-y-1">
<span className="text-[10px] text-slate-400 font-bold uppercase block">Case Status</span>
<span className={`block px-2.5 py-0.5 rounded-full text-[10px] font-black uppercase w-fit ${
selectedTicket.status === 'Resolved' ? 'bg-emerald-50 text-emerald-700' : 'bg-orange-50 text-orange-700'
}`}>
Status: {selectedTicket.status}
</span>
</div>
<div className="space-y-1">
<span className="text-[10px] text-slate-400 font-bold uppercase block">Created Date</span>
<span className="font-extrabold text-slate-800 block">{selectedTicket.created_at}</span>
<span className="text-[10px] text-slate-400 font-semibold block">Case active since last update</span>
</div>
</div>
{/* Dual Panel Workspace */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Chat Log Transcript */}
<div className="bg-white border border-slate-200 rounded-2xl p-5 shadow-sm flex flex-col h-[480px]">
<div className="border-b pb-3">
<h3 className="text-xs font-black text-slate-800 uppercase tracking-widest flex items-center gap-1.5">
<MessageSquare className="w-4.5 h-4.5 text-[#0F6E56]" />
<span>Direct Chat logs Transcript</span> <span>Direct Chat logs Transcript</span>
</h3> </h3>
<p className="text-[9px] text-slate-400 font-semibold mt-1">Official authenticated conversation audit log</p> <p className="text-[10px] text-slate-400 font-semibold mt-1">Official authenticated conversation history audits</p>
</div> </div>
{/* Conversation thread */} {/* Scroll thread */}
<div className="flex-1 overflow-y-auto space-y-3 my-4 pr-2 max-h-[360px]"> <div className="flex-1 overflow-y-auto space-y-4 my-4 pr-1 scrollbar-thin">
{selectedTicket.chat_logs.map((log, i) => { {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 ( return (
<div <div
key={i} key={i}
className={`max-w-[85%] p-3 rounded-2xl text-[11px] font-bold shadow-sm ${ className={`max-w-[90%] p-3 rounded-2xl text-[11px] font-bold shadow-sm ${
isEmployer isMediator
? 'bg-emerald-50 text-emerald-950 border border-emerald-250 mx-auto'
: isPartyTwo
? 'bg-blue-50 text-blue-900 mr-auto border border-blue-100' ? 'bg-blue-50 text-blue-900 mr-auto border border-blue-100'
: 'bg-teal-50 text-teal-900 ml-auto border border-teal-100' : 'bg-indigo-50 text-indigo-900 ml-auto border border-indigo-100'
}`} }`}
> >
<span className="text-[8px] font-black uppercase text-slate-400 block mb-1">{log.sender} {log.time}</span> <span className="text-[8px] font-black uppercase text-slate-400 block mb-1">{log.sender} {log.time}</span>
@ -135,101 +417,80 @@ 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-semibold text-center uppercase tracking-wide"> <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 Vetting Compliance Logs Digitally Secured by UAE Vetting Compliance
</div> </div>
</div> </div>
{/* Evidence & Resolution Tools */} {/* Tools panel */}
<div className="space-y-6"> <div className="space-y-6">
{/* Evidence Files */} {/* Evidence Files */}
<div className="bg-white border border-slate-200 rounded-3xl p-5 shadow-sm space-y-3"> <div className="bg-white border border-slate-200 rounded-2xl p-5 shadow-sm space-y-3">
<h3 className="text-xs font-black text-slate-800 uppercase tracking-widest flex items-center gap-1"> <h3 className="text-xs font-black text-slate-800 uppercase tracking-widest flex items-center gap-1.5">
<FileText className="w-4 h-4 text-[#0F6E56]" /> <FileText className="w-4.5 h-4.5 text-[#0F6E56]" />
<span>Evidence Files</span> <span>Case Evidence Files</span>
</h3> </h3>
{selectedTicket.evidence?.length > 0 ? (
<div className="space-y-2"> <div className="space-y-2">
{selectedTicket.evidence.map((file, i) => ( {selectedTicket.evidence?.map((file, i) => (
<div key={i} className="flex items-center justify-between p-2.5 bg-slate-50 rounded-xl border text-xs font-bold text-slate-700"> <div key={i} className="flex items-center justify-between p-2.5 bg-slate-50 rounded-xl border border-slate-150 text-xs font-bold text-slate-700 shadow-sm hover:bg-slate-100 transition-colors">
<div className="flex items-center gap-2">
<FileText className="w-4 h-4 text-slate-400" />
<span>{file.name} ({file.type})</span> <span>{file.name} ({file.type})</span>
</div>
<a href={file.url} className="text-[#0F6E56] hover:underline flex items-center gap-0.5"> <a href={file.url} className="text-[#0F6E56] hover:underline flex items-center gap-0.5">
<span>View File</span> <span>View</span>
<ArrowUpRight className="w-3.5 h-3.5" /> <ArrowUpRight className="w-3.5 h-3.5" />
</a> </a>
</div> </div>
))} ))}
</div> </div>
) : (
<p className="text-[10px] text-slate-400 font-semibold uppercase">No attachment proofs uploaded.</p>
)}
</div> </div>
{/* Admin Notes */} {/* Simplified Action Panel */}
<div className="bg-yellow-50/50 border border-yellow-200 rounded-3xl p-5 shadow-sm space-y-3"> <div className="bg-white border border-slate-200 rounded-2xl p-5 shadow-sm space-y-4">
<h3 className="text-xs font-black text-yellow-800 uppercase tracking-widest">Internal Compliance Notes</h3> <div>
<p className="text-[11px] text-slate-600 bg-white p-3 rounded-xl border border-yellow-100 font-semibold leading-relaxed whitespace-pre-line"> <h3 className="text-xs font-black text-slate-800 uppercase tracking-widest flex items-center gap-2">
{selectedTicket.admin_notes} <MessageSquare className="w-4 h-4 text-[#0F6E56]" />
</p> <span>Mediator Reply & Actions</span>
<form onSubmit={handleAddNote} className="flex gap-2"> </h3>
<input <p className="text-[10px] text-slate-400 font-semibold mt-1">Send a message to both parties or resolve the dispute.</p>
type="text" </div>
className="flex-1 bg-white border border-slate-200 rounded-xl px-3 py-2 text-xs font-bold focus:ring-2 focus:ring-yellow-500/10 outline-none"
placeholder="Write compliance log note..." <form onSubmit={handleAddNote} className="space-y-3">
<textarea
rows="3"
className="w-full bg-white border border-slate-200 rounded-xl p-3 text-xs font-semibold focus:ring-2 focus:ring-[#0F6E56]/10 outline-none"
placeholder="Write your response or internal compliance update..."
value={internalNote} value={internalNote}
onChange={e => setInternalNote(e.target.value)} onChange={e => setInternalNote(e.target.value)}
/> />
<button type="submit" className="p-2 bg-[#0F6E56] text-white rounded-xl"> <div className="flex gap-2">
<Send className="w-4.5 h-4.5" /> <button
</button> type="submit"
</form> className="flex-1 py-2.5 bg-[#0F6E56] hover:bg-[#085041] text-white rounded-xl text-[10px] font-black uppercase tracking-wider transition-colors shadow-sm"
</div>
{/* Resolution Form */}
{selectedTicket.status !== 'Resolved' && (
<div className="bg-white border border-slate-200 rounded-3xl p-5 shadow-sm space-y-4">
<div>
<h3 className="text-xs font-black text-slate-800 uppercase tracking-widest">Settle Ticket Resolution</h3>
<p className="text-[10px] text-slate-400 font-semibold mt-1">Submit official dispute settlement outcome</p>
</div>
<form onSubmit={handleResolve} className="space-y-3 text-xs font-bold text-slate-700">
<div className="space-y-1">
<label className="text-[9px] font-black text-slate-400 uppercase tracking-widest">Resolution Verdict</label>
<select
className="w-full bg-slate-50 border rounded-xl px-3 py-2.5 outline-none cursor-pointer focus:bg-white"
value={resolutionAction}
onChange={e => setResolutionAction(e.target.value)}
> >
<option value="refund_employer">Approve Full Refund to Employer (AED {selectedTicket.amount_aed})</option> Send Reply
<option value="partial_refund">Approve 50% split partial refund</option>
<option value="payout_worker">Dismiss dispute & release payout to worker</option>
</select>
</div>
<div className="space-y-1">
<textarea
rows="2"
placeholder="Provide brief explanation for payment release/refund decisions..."
className="w-full bg-white border rounded-xl p-3 font-semibold focus:ring-2 focus:ring-[#0F6E56]/10 outline-none"
value={resolutionSummary}
onChange={e => setResolutionSummary(e.target.value)}
/>
</div>
<button type="submit" className="w-full py-2.5 bg-slate-900 hover:bg-slate-800 text-white rounded-xl text-[10px] font-black uppercase tracking-widest shadow-md">
Submit Official Verdict
</button> </button>
{selectedTicket.status !== 'Resolved' && (
<button
type="button"
onClick={handleResolve}
className="px-4 py-2.5 bg-red-600 hover:bg-red-700 text-white rounded-xl text-[10px] font-black uppercase tracking-wider transition-colors shadow-sm"
>
Close Ticket
</button>
)}
</div>
</form> </form>
</div> </div>
)}
</div> </div>
</div> </div>
) : ( </div>
<div className="bg-white rounded-3xl border p-20 text-center text-slate-400 font-bold"> </div>
Select a dispute ticket from the left panel.
</div> </div>
)} )}
</div>
</div>
</div>
</AdminLayout> </AdminLayout>
); );
} }

View File

@ -2,21 +2,13 @@ import React, { useState } from 'react';
import { Head, router } from '@inertiajs/react'; import { Head, router } from '@inertiajs/react';
import AdminLayout from '@/Layouts/AdminLayout'; import AdminLayout from '@/Layouts/AdminLayout';
import { import {
BellRing,
Send, Send,
Smartphone,
MessageCircle,
CheckCircle,
BarChart3,
Users,
AlertTriangle,
History, History,
Sparkles, Sparkles
TrendingUp
} from 'lucide-react'; } from 'lucide-react';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
export default function NotificationsCampaigns({ campaigns, whatsapp_templates }) { export default function NotificationsCampaigns({ campaigns = [] }) {
const [channel, setChannel] = useState('push'); const [channel, setChannel] = useState('push');
const [recipients, setRecipients] = useState('All Active Workers'); const [recipients, setRecipients] = useState('All Active Workers');
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
@ -38,83 +30,77 @@ export default function NotificationsCampaigns({ campaigns, whatsapp_templates }
}; };
return ( return (
<AdminLayout title="Campaigns & Notifications Hub"> <AdminLayout title="Notifications">
<Head title="Campaigns & Alerts" /> <Head title="Notifications" />
<div className="font-sans max-w-7xl mx-auto space-y-8"> <div className="font-sans max-w-4xl mx-auto space-y-8 pb-12">
{/* Header overview and status */} {/* Simplified Header */}
<div> <div className="border-b border-slate-200 pb-5">
<h1 className="text-xl font-bold text-gray-900 tracking-tight">System Campaign Broadcasting Center</h1> <h1 className="text-xl font-black text-slate-800 uppercase tracking-tight">Send Notification</h1>
<p className="text-xs text-gray-500 mt-0.5">Reach users directly via SMS, WhatsApp, and Native Push notifications. Monitor open rates and clicks.</p> <p className="text-xs text-slate-500 mt-0.5 font-medium">Send quick push or WhatsApp alerts to workers and sponsors.</p>
</div> </div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 items-start"> <div className="grid grid-cols-1 gap-8">
{/* Column 1 & 2: Broadcast Composer & Campaigns History */} {/* Message Composer */}
<div className="lg:col-span-2 space-y-8">
{/* Section 1: Composer */}
<div className="bg-white border border-slate-200 rounded-3xl p-6 shadow-sm"> <div className="bg-white border border-slate-200 rounded-3xl p-6 shadow-sm">
<div className="mb-6 flex items-center justify-between"> <div className="mb-6 flex items-center justify-between">
<div> <div>
<h3 className="text-sm font-black text-slate-800 uppercase tracking-widest flex items-center gap-1"> <h3 className="text-sm font-black text-slate-800 uppercase tracking-widest flex items-center gap-1.5">
<Sparkles className="w-4 h-4 text-[#0F6E56]" /> <Sparkles className="w-4 h-4 text-[#0F6E56]" />
<span>Direct Campaign Composer</span> <span>New Notification Alert</span>
</h3> </h3>
<p className="text-[10px] text-slate-400 font-semibold mt-1">Schedules immediate or recurring broadcasts</p> <p className="text-[10px] text-slate-400 font-semibold mt-1">Send a dynamic message immediately</p>
</div> </div>
<Badge className="bg-emerald-50 text-emerald-700 border-none font-bold uppercase text-[8px] tracking-wider">SMS Gateway: Active</Badge>
</div> </div>
<form onSubmit={handleBroadcast} className="space-y-4 text-xs font-bold text-slate-700"> <form onSubmit={handleBroadcast} className="space-y-4 text-xs font-bold text-slate-700">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-1"> <div className="space-y-1">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Select Channel</label> <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Message Channel</label>
<select <select
className="w-full bg-slate-50 border rounded-xl px-3 py-2.5 outline-none cursor-pointer focus:bg-white" className="w-full bg-slate-50 border rounded-xl px-3 py-2.5 outline-none cursor-pointer focus:bg-white"
value={channel} value={channel}
onChange={e => setChannel(e.target.value)} onChange={e => setChannel(e.target.value)}
> >
<option value="push">Native App Push Notification</option> <option value="push">App Message</option>
<option value="whatsapp">WhatsApp Business API</option> <option value="whatsapp">WhatsApp Message</option>
<option value="both">Both (Multi-channel coverage)</option> <option value="both">Both Channels</option>
</select> </select>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Target User Cohort</label> <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Send To</label>
<select <select
className="w-full bg-slate-50 border rounded-xl px-3 py-2.5 outline-none cursor-pointer focus:bg-white" className="w-full bg-slate-50 border rounded-xl px-3 py-2.5 outline-none cursor-pointer focus:bg-white"
value={recipients} value={recipients}
onChange={e => setRecipients(e.target.value)} onChange={e => setRecipients(e.target.value)}
> >
<option value="All Active Workers">All Active Workers (1,250 users)</option> <option value="All Active Workers">All Workers</option>
<option value="Unverified Workers (Morocco & Philippines)">Unverified Workers Pool (142 users)</option> <option value="All Employers">All Sponsors</option>
<option value="Premium Employers">Premium Employers only (312 users)</option>
<option value="All Employers">All Registered Employers (380 users)</option>
</select> </select>
</div> </div>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Notification Title</label> <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Message Title</label>
<input <input
type="text" type="text"
required required
className="w-full bg-slate-50 border rounded-xl px-3 py-2.5 outline-none focus:bg-white focus:ring-2 focus:ring-[#0F6E56]/10" className="w-full bg-slate-50 border rounded-xl px-3 py-2.5 outline-none focus:bg-white focus:ring-2 focus:ring-[#0F6E56]/10"
placeholder="e.g., Emirates ID Verification Required" placeholder="e.g. Account Update Alert"
value={title} value={title}
onChange={e => setTitle(e.target.value)} onChange={e => setTitle(e.target.value)}
/> />
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Notification Text Message</label> <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Message Text</label>
<textarea <textarea
rows="3" rows="3"
required required
className="w-full bg-slate-50 border rounded-xl p-3 outline-none focus:bg-white focus:ring-2 focus:ring-[#0F6E56]/10" className="w-full bg-slate-50 border rounded-xl p-3 outline-none focus:bg-white focus:ring-2 focus:ring-[#0F6E56]/10"
placeholder="Type the message content. Use tags like {{name}} to personalize matches..." placeholder="Type the message..."
value={message} value={message}
onChange={e => setMessage(e.target.value)} onChange={e => setMessage(e.target.value)}
/> />
@ -122,19 +108,19 @@ export default function NotificationsCampaigns({ campaigns, whatsapp_templates }
<button type="submit" className="w-full py-3 bg-[#0F6E56] text-white rounded-xl text-[10px] font-black uppercase tracking-widest shadow-md hover:bg-[#085041] flex items-center justify-center gap-1.5"> <button type="submit" className="w-full py-3 bg-[#0F6E56] text-white rounded-xl text-[10px] font-black uppercase tracking-widest shadow-md hover:bg-[#085041] flex items-center justify-center gap-1.5">
<Send className="w-4 h-4" /> <Send className="w-4 h-4" />
<span>Broadcast System Campaign</span> <span>Send Message</span>
</button> </button>
</form> </form>
</div> </div>
{/* Section 2: Campaigns History */} {/* Recently Sent Logs */}
<div className="bg-white border border-slate-200 rounded-3xl p-6 shadow-sm overflow-hidden"> <div className="bg-white border border-slate-200 rounded-3xl p-6 shadow-sm overflow-hidden">
<div className="mb-4"> <div className="mb-4">
<h3 className="text-sm font-black text-slate-800 uppercase tracking-widest flex items-center gap-1.5"> <h3 className="text-sm font-black text-slate-800 uppercase tracking-widest flex items-center gap-1.5">
<History className="w-4 h-4 text-slate-600" /> <History className="w-4 h-4 text-slate-600" />
<span>Broadcast Campaign Logs</span> <span>History</span>
</h3> </h3>
<p className="text-[10px] text-slate-400 font-semibold mt-0.5">Logs of recently delivered campaigns</p> <p className="text-[10px] text-slate-400 font-semibold mt-0.5">Logs of recently sent alerts</p>
</div> </div>
<div className="divide-y divide-slate-100"> <div className="divide-y divide-slate-100">
@ -142,8 +128,8 @@ export default function NotificationsCampaigns({ campaigns, whatsapp_templates }
<div key={camp.id} className="py-4 hover:bg-slate-50/50 transition-colors flex items-start justify-between gap-4 text-xs font-bold text-slate-700"> <div key={camp.id} className="py-4 hover:bg-slate-50/50 transition-colors flex items-start justify-between gap-4 text-xs font-bold text-slate-700">
<div className="space-y-1.5 flex-1"> <div className="space-y-1.5 flex-1">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Badge className="bg-blue-50 text-blue-700 border-none font-bold uppercase text-[8px]">{camp.channel}</Badge> <Badge className="bg-[#0F6E56]/10 text-[#0F6E56] border-none font-bold uppercase text-[8px]">{camp.channel}</Badge>
<span className="text-[10px] text-slate-400 font-semibold">Cohort: {camp.recipient_type}</span> <span className="text-[10px] text-slate-400 font-semibold">To: {camp.recipient_type}</span>
</div> </div>
<h4 className="text-sm font-black text-slate-900">{camp.title}</h4> <h4 className="text-sm font-black text-slate-900">{camp.title}</h4>
<p className="text-slate-500 font-medium text-[11px] leading-relaxed">{camp.message}</p> <p className="text-slate-500 font-medium text-[11px] leading-relaxed">{camp.message}</p>
@ -151,7 +137,7 @@ export default function NotificationsCampaigns({ campaigns, whatsapp_templates }
<div className="text-right"> <div className="text-right">
<span className="text-[9px] text-slate-400 font-mono block">{camp.sent_at}</span> <span className="text-[9px] text-slate-400 font-mono block">{camp.sent_at}</span>
<div className="mt-2 text-[10px] font-black text-emerald-600 uppercase tracking-wide"> <div className="mt-2 text-[10px] font-black text-emerald-600 uppercase tracking-wide">
{camp.delivery_rate} Delivered {camp.clicks} Sent Successfully
</div> </div>
</div> </div>
</div> </div>
@ -160,53 +146,6 @@ export default function NotificationsCampaigns({ campaigns, whatsapp_templates }
</div> </div>
</div> </div>
{/* Column 3: Pre-approved WhatsApp Templates */}
<div className="space-y-6">
<div className="bg-white border border-slate-200 rounded-3xl p-6 shadow-sm space-y-4">
<div>
<h3 className="text-sm font-black text-slate-800 uppercase tracking-widest flex items-center gap-1">
<MessageCircle className="w-4.5 h-4.5 text-emerald-600" />
<span>WhatsApp Templates</span>
</h3>
<p className="text-[10px] text-slate-400 mt-0.5">Meta pre-approved utility check-in templates</p>
</div>
<div className="space-y-3">
{whatsapp_templates.map((tpl, i) => (
<div key={i} className="p-3 bg-slate-50 rounded-xl border border-slate-100 text-xs font-bold text-slate-700 space-y-2">
<div className="flex items-center justify-between">
<span className="font-black text-slate-800 text-[10px] font-mono leading-none">{tpl.name}</span>
<Badge className={`border-none text-[8px] font-black uppercase ${
tpl.status === 'Approved' ? 'bg-emerald-50 text-emerald-700' : 'bg-amber-50 text-amber-700'
}`}>{tpl.status}</Badge>
</div>
<p className="text-[10px] text-slate-500 font-medium leading-relaxed bg-white p-2 rounded-lg border border-slate-100">{tpl.text}</p>
<div className="text-[8px] text-slate-400 uppercase tracking-widest font-black">Category: {tpl.category} Language: {tpl.language}</div>
</div>
))}
</div>
</div>
{/* Interactive campaign delivery metrics */}
<div className="bg-blue-50/40 border border-blue-100 p-5 rounded-3xl shadow-sm space-y-3 text-xs font-bold text-slate-700">
<span className="text-[10px] font-black text-blue-900 uppercase tracking-widest flex items-center gap-1">
<TrendingUp className="w-4 h-4" />
<span>Engagement Metrics</span>
</span>
<div className="grid grid-cols-2 gap-4 pt-1">
<div className="bg-white p-3 rounded-xl border border-blue-50 text-center">
<span className="text-[9px] text-slate-400 uppercase tracking-wider block">Push Open Rate</span>
<span className="text-lg font-black text-slate-800">42.8%</span>
</div>
<div className="bg-white p-3 rounded-xl border border-blue-50 text-center">
<span className="text-[9px] text-slate-400 uppercase tracking-wider block">WhatsApp Click Rate</span>
<span className="text-lg font-black text-emerald-600">68.5%</span>
</div>
</div>
</div>
</div>
</div>
</div> </div>
</AdminLayout> </AdminLayout>
); );

View File

@ -13,7 +13,14 @@ import {
UserX, UserX,
Settings, Settings,
FileText, FileText,
MessageSquare MessageSquare,
Bell,
Calendar,
Search,
ChevronDown,
MoreVertical,
Clock,
Shield
} from 'lucide-react'; } from 'lucide-react';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { import {
@ -29,110 +36,328 @@ export default function SafetyHub({ reports, rules, moderation_queue }) {
const [resolutionAction, setResolutionAction] = useState('warn'); const [resolutionAction, setResolutionAction] = useState('warn');
const [adminNotes, setAdminNotes] = useState(''); const [adminNotes, setAdminNotes] = useState('');
// Tab and filter states
const [activeTab, setActiveTab] = useState('All Reports');
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState('All Status');
const [typeFilter, setTypeFilter] = useState('All Types');
const handleResolve = (e) => { const handleResolve = (e) => {
e.preventDefault(); e.preventDefault();
if (!selectedReport) return;
router.post(`/admin/safety/reports/${selectedReport.id}/resolve`, { router.post(`/admin/safety/reports/${selectedReport.id}/resolve`, {
action: resolutionAction, action: resolutionAction,
admin_notes: adminNotes admin_notes: adminNotes
}, { }, {
onSuccess: () => { onSuccess: () => {
setIsDialogOpen(false); setIsDialogOpen(false);
setSelectedReport(null);
setAdminNotes('');
} }
}); });
}; };
const handleModQueue = (id, action) => { // Filter reports based on active tab, search query, type, and status
router.post(`/admin/safety/reports/${id}/resolve`, { action }, { const filteredReports = (reports || []).filter(report => {
onSuccess: () => { // Tab filter
setIsDialogOpen(false); if (activeTab !== 'All Reports') {
if (activeTab === 'Profiles' && report.type !== 'Profile') return false;
if (activeTab === 'Chats' && report.type !== 'Chat') return false;
if (activeTab === 'Reviews' && report.type !== 'Review') return false;
if (activeTab === 'Images' && report.type !== 'Image') return false;
if (activeTab === 'Other Content' && report.type === 'Profile' || report.type === 'Chat' || report.type === 'Review' || report.type === 'Image') return false;
} }
// Type filter dropdown
if (typeFilter !== 'All Types' && report.type !== typeFilter) return false;
// Status filter dropdown
if (statusFilter !== 'All Status' && report.status !== statusFilter) return false;
// Search query
if (searchQuery.trim() !== '') {
const query = searchQuery.toLowerCase();
return (
report.id.toLowerCase().includes(query) ||
report.reason.toLowerCase().includes(query) ||
report.reported_user_name.toLowerCase().includes(query) ||
report.reported_by_name.toLowerCase().includes(query)
);
}
return true;
}); });
};
return ( return (
<AdminLayout title="Safety & Moderation Center"> <AdminLayout title="Content Moderation">
<Head title="Safety & Moderation" /> <Head title="Content Moderation" />
<div className="font-sans max-w-7xl mx-auto space-y-8"> <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-xl font-bold text-gray-900 tracking-tight">Trust & Safety Escalation Center</h1> <h1 className="text-2xl font-bold text-slate-900 tracking-tight">Content Moderation</h1>
<p className="text-xs text-gray-500 mt-0.5">Manage user abuse reports, content moderation queues, and customize automatic fraud filters.</p> <p className="text-sm text-slate-500 mt-1">Review and take action on reported content, users and reviews.</p>
</div> </div>
<div className="flex items-center gap-3">
<button className="inline-flex items-center px-4 py-2.5 bg-white border border-gray-200 text-[#0F6E56] rounded-xl text-xs font-bold hover:bg-gray-50 transition-colors shadow-sm space-x-2 uppercase tracking-wider"> {/* Header Controls */}
<Settings className="w-4 h-4" /> <div className="flex items-center gap-3 self-end md:self-auto">
<span>System Rule Rules</span> <button className="relative p-2.5 bg-white border border-slate-200 rounded-xl hover:bg-slate-50 transition-colors shadow-sm text-slate-600">
<Bell className="w-5 h-5" />
<span className="absolute top-1.5 right-1.5 w-4 h-4 bg-red-500 text-white rounded-full text-[9px] font-bold flex items-center justify-center">12</span>
</button> </button>
<div className="flex items-center gap-2 px-4 py-2.5 bg-white border border-slate-200 rounded-xl hover:bg-slate-50 transition-colors shadow-sm text-xs font-semibold text-slate-700 cursor-pointer">
<Calendar className="w-4 h-4 text-slate-400" />
<span>May 13, 2025 - Jun 11, 2025</span>
<ChevronDown className="w-4 h-4 text-slate-400" />
</div>
</div> </div>
</div> </div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8"> {/* Upper Row Metric Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{/* Column 1 & 2: Abuse Reports & Content Queue */} {/* Card 1: Total Reports */}
<div className="lg:col-span-2 space-y-8"> <div className="bg-white p-5 border border-slate-200 rounded-2xl shadow-sm flex items-center justify-between">
<div className="space-y-1">
{/* Section 1: Abuse Reports */} <span className="text-xs text-slate-500 font-semibold block">Total Reports</span>
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden"> <span className="text-2xl font-extrabold text-slate-900 block">128</span>
<div className="p-5 border-b border-slate-100 flex items-center justify-between bg-slate-50/20"> <span className="inline-flex items-center gap-0.5 px-2 py-0.5 bg-emerald-50 text-emerald-700 rounded-full text-[10px] font-bold">
<div> 12% <span className="font-normal text-slate-400 ml-1">from last 30 days</span>
<h3 className="text-sm font-black text-slate-800 uppercase tracking-widest">Active Incident Reports</h3> </span>
<p className="text-[10px] text-slate-400 mt-0.5">Escalated complaints filed by employers or workers</p> </div>
<div className="p-3 bg-[#0F6E56]/10 text-[#0F6E56] rounded-2xl">
<Flag className="w-6 h-6" />
</div> </div>
<Badge className="bg-red-50 text-red-700 border-none font-bold uppercase tracking-wider text-[8px]">{reports?.length || 0} Open Tickets</Badge>
</div> </div>
<div className="divide-y divide-slate-100"> {/* Card 2: Pending Review */}
{reports.map((report) => ( <div className="bg-white p-5 border border-slate-200 rounded-2xl shadow-sm flex items-center justify-between">
<div key={report.id} className="p-5 hover:bg-slate-50/50 transition-colors flex items-start justify-between gap-4 text-xs font-bold text-slate-700"> <div className="space-y-1">
<div className="space-y-2 flex-1"> <span className="text-xs text-slate-500 font-semibold block">Pending Review</span>
<div className="flex items-center space-x-2"> <span className="text-2xl font-extrabold text-slate-900 block">42</span>
<Badge className={`border-none text-[8px] font-black uppercase ${ <span className="text-[11px] text-amber-600 font-bold block">Requires attention</span>
report.severity === 'High' ? 'bg-red-100 text-red-700' :
report.severity === 'Medium' ? 'bg-amber-100 text-amber-700' : 'bg-slate-100 text-slate-700'
}`}>
{report.severity} Severity
</Badge>
<span className="text-[10px] text-slate-400 font-mono">{report.id}</span>
<span className="text-slate-300"></span>
<span className="text-slate-400">Reporter: {report.reporter}</span>
</div> </div>
<div> <div className="p-3 bg-amber-50 text-amber-600 rounded-2xl">
<h4 className="text-sm font-black text-slate-900">{report.reason}</h4> <Clock className="w-6 h-6" />
<p className="text-slate-500 font-medium mt-1 leading-relaxed">{report.description}</p>
</div> </div>
<div className="text-[10px] text-slate-400 font-semibold uppercase">Reported User: <span className="text-slate-900 font-bold">{report.reported_user}</span></div>
</div> </div>
<div className="flex flex-col items-end gap-2">
<span className="text-[9px] text-slate-400 font-mono">{report.created_at}</span> {/* Card 4: Resolved */}
<div className="bg-white p-5 border border-slate-200 rounded-2xl shadow-sm flex items-center justify-between">
<div className="space-y-1">
<span className="text-xs text-slate-500 font-semibold block">Resolved</span>
<span className="text-2xl font-extrabold text-slate-900 block">78</span>
<span className="text-[11px] text-emerald-600 font-bold block">This period</span>
</div>
<div className="p-3 bg-emerald-50 text-emerald-600 rounded-2xl">
<CheckCircle className="w-6 h-6" />
</div>
</div>
{/* Card 5: Actions Taken */}
<div className="bg-white p-5 border border-slate-200 rounded-2xl shadow-sm flex items-center justify-between">
<div className="space-y-1">
<span className="text-xs text-slate-500 font-semibold block">Actions Taken</span>
<span className="text-2xl font-extrabold text-slate-900 block">56</span>
<span className="text-[11px] text-blue-600 font-bold block">Warnings/Suspensions</span>
</div>
<div className="p-3 bg-blue-50 text-blue-600 rounded-2xl">
<Shield className="w-6 h-6" />
</div>
</div>
</div>
{/* Tabs & Controls Section */}
<div className="bg-white border border-slate-200 rounded-2xl shadow-sm overflow-hidden">
<div className="p-4 border-b border-slate-100 flex flex-col lg:flex-row lg:items-center justify-between gap-4 bg-slate-50/20">
{/* Tabs list */}
<div className="flex items-center overflow-x-auto gap-2 -mb-4 lg:mb-0 pb-2 lg:pb-0 scrollbar-none">
{['All Reports', 'Profiles', 'Chats', 'Reviews'].map((tab) => (
<button <button
onClick={() => { setSelectedReport(report); setIsDialogOpen(true); }} key={tab}
className="px-3.5 py-1.5 bg-slate-900 hover:bg-slate-800 text-white rounded-lg font-black text-[9px] uppercase tracking-wider" onClick={() => setActiveTab(tab)}
className={`px-4 py-2 text-xs font-bold whitespace-nowrap transition-all rounded-lg ${
activeTab === tab
? 'bg-[#0F6E56]/10 text-[#0F6E56] font-black shadow-sm'
: 'text-slate-500 hover:text-slate-800'
}`}
> >
Investigate {tab}
</button> </button>
</div>
</div>
))} ))}
</div> </div>
{/* Search & Select Controls */}
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3">
{/* Type filter */}
<select
value={typeFilter}
onChange={e => setTypeFilter(e.target.value)}
className="bg-white border border-slate-200 text-slate-700 text-xs font-bold px-3 py-2 rounded-xl outline-none cursor-pointer hover:bg-slate-50 transition-colors shadow-sm"
>
<option value="All Types">All Types</option>
<option value="Profile">Profile</option>
<option value="Chat">Chat</option>
<option value="Review">Review</option>
<option value="Image">Image</option>
</select>
{/* Status filter */}
<select
value={statusFilter}
onChange={e => setStatusFilter(e.target.value)}
className="bg-white border border-slate-200 text-slate-700 text-xs font-bold px-3 py-2 rounded-xl outline-none cursor-pointer hover:bg-slate-50 transition-colors shadow-sm"
>
<option value="All Status">All Status</option>
<option value="Pending">Pending</option>
<option value="In Review">In Review</option>
<option value="Resolved">Resolved</option>
</select>
{/* Search Box */}
<div className="relative">
<Search className="w-4 h-4 text-slate-400 absolute left-3.5 top-1/2 -translate-y-1/2" />
<input
type="text"
placeholder="Search reports..."
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
className="bg-white border border-slate-200 pl-10 pr-4 py-2 rounded-xl text-xs font-semibold focus:outline-none focus:ring-2 focus:ring-[#0F6E56]/20 w-full sm:w-60 shadow-sm"
/>
</div>
</div>
</div> </div>
{/* Section 2: Content Moderation Queue */} {/* Table Row Content */}
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden"> <div className="overflow-x-auto">
<div className="p-5 border-b border-slate-100 bg-slate-50/20"> <table className="w-full text-left border-collapse text-xs font-semibold text-slate-600">
<thead>
<tr className="border-b border-slate-100 bg-slate-50/50 text-slate-400 font-bold uppercase tracking-wider text-[10px]">
<th className="px-5 py-4 w-10">
<input type="checkbox" className="rounded border-slate-300 text-[#0F6E56] focus:ring-[#0F6E56] cursor-pointer" />
</th>
<th className="px-5 py-4">Report ID</th>
<th className="px-5 py-4">Type</th>
<th className="px-5 py-4">Reported User</th>
<th className="px-5 py-4">Reported By</th>
<th className="px-5 py-4">Reason</th>
<th className="px-5 py-4">Status</th>
<th className="px-5 py-4">Reported At</th>
<th className="px-5 py-4 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{filteredReports.map((report) => (
<tr key={report.id} className="hover:bg-slate-50/40 transition-colors">
<td className="px-5 py-4">
<input type="checkbox" className="rounded border-slate-300 text-[#0F6E56] focus:ring-[#0F6E56] cursor-pointer" />
</td>
<td className="px-5 py-4 font-mono font-bold text-[#0F6E56] hover:underline cursor-pointer">
{report.id}
</td>
<td className="px-5 py-4">
<span className={`px-2.5 py-1 rounded-full text-[10px] font-black uppercase ${
report.type === 'Profile' ? 'bg-purple-100 text-purple-700' :
report.type === 'Chat' ? 'bg-cyan-100 text-cyan-700' :
report.type === 'Review' ? 'bg-emerald-100 text-emerald-700' : 'bg-orange-100 text-orange-700'
}`}>
{report.type}
</span>
</td>
<td className="px-5 py-4">
<div className="flex items-center gap-2.5">
<img
src={report.reported_user_avatar || 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?auto=format&fit=crop&q=80&w=100'}
alt={report.reported_user_name}
className="w-7 h-7 rounded-full object-cover shadow-sm border border-slate-100"
/>
<div>
<span className="font-extrabold text-slate-800 block">{report.reported_user_name}</span>
<span className="text-[10px] text-slate-400 font-semibold block">{report.reported_user_role}</span>
</div>
</div>
</td>
<td className="px-5 py-4">
<div className="flex items-center gap-2.5">
<img
src={report.reported_by_avatar || 'https://images.unsplash.com/photo-1544005313-94ddf0286df2?auto=format&fit=crop&q=80&w=100'}
alt={report.reported_by_name}
className="w-7 h-7 rounded-full object-cover shadow-sm border border-slate-100"
/>
<div>
<span className="font-extrabold text-slate-800 block">{report.reported_by_name}</span>
<span className="text-[10px] text-slate-400 font-semibold block">{report.reported_by_role}</span>
</div>
</div>
</td>
<td className="px-5 py-4 font-semibold text-slate-800">
{report.reason}
</td>
<td className="px-5 py-4">
<span className={`px-2 py-0.5 rounded-full text-[10px] font-black uppercase ${
report.status === 'Pending' ? 'bg-orange-50 text-orange-700' :
report.status === 'In Review' ? 'bg-blue-50 text-blue-700' : 'bg-emerald-50 text-emerald-700'
}`}>
{report.status}
</span>
</td>
<td className="px-5 py-4 text-slate-400 font-semibold">
{report.reported_at}
</td>
<td className="px-5 py-4 text-right">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => { setSelectedReport(report); setIsDialogOpen(true); }}
className="px-3 py-1.5 bg-white border border-slate-200 text-slate-700 rounded-lg hover:bg-slate-50 transition-colors shadow-sm font-bold uppercase tracking-wider text-[10px]"
>
View
</button>
<button
onClick={() => { setSelectedReport(report); setIsDialogOpen(true); }}
className="px-3.5 py-1.5 bg-[#0F6E56] hover:bg-[#085041] text-white rounded-lg transition-colors font-black uppercase tracking-wider text-[10px]"
>
Take Action
</button>
</div>
</td>
</tr>
))}
{filteredReports.length === 0 && (
<tr>
<td colSpan="10" className="p-10 text-center font-bold text-slate-400 uppercase">
No incident reports found matching your criteria.
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
{/* Subsections: Vetting Queue & Auto-Detection Rules */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Content Vetting Queue */}
<div className="lg:col-span-2 bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden">
<div className="p-5 border-b border-slate-100 flex items-center justify-between bg-slate-50/20">
<div>
<h3 className="text-sm font-black text-slate-800 uppercase tracking-widest">Profile Content Vetting Queue</h3> <h3 className="text-sm font-black text-slate-800 uppercase tracking-widest">Profile Content Vetting Queue</h3>
<p className="text-[10px] text-slate-400 mt-0.5">Bio updates or pictures flagged for manual review</p> <p className="text-[10px] text-slate-400 mt-0.5">Bio updates or pictures flagged for manual review</p>
</div> </div>
<Badge className="bg-amber-50 text-amber-700 border-none font-bold uppercase tracking-wider text-[8px]">Action Required</Badge>
</div>
<div className="divide-y divide-slate-100"> <div className="divide-y divide-slate-100">
{moderation_queue.map((item) => ( {moderation_queue.map((item) => (
<div key={item.id} className="p-5 hover:bg-slate-50/30 transition-colors grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-bold text-slate-700"> <div key={item.id} className="p-5 hover:bg-slate-50/20 transition-colors grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-bold text-slate-700">
<div> <div>
<span className="text-[9px] text-slate-400 uppercase tracking-widest block font-mono">{item.id} {item.type}</span> <span className="text-[9px] text-slate-400 uppercase tracking-widest block font-mono">{item.id} {item.type}</span>
<span className="text-sm font-black text-slate-900 block mt-1">{item.user}</span> <span className="text-sm font-black text-slate-900 block mt-1">{item.user}</span>
<Badge className="mt-2 bg-amber-50 text-amber-700 border-none font-bold uppercase tracking-wider text-[8px]">{item.flag}</Badge> <Badge className="mt-2 bg-red-50 text-red-700 border-none font-bold uppercase tracking-wider text-[8px]">{item.flag}</Badge>
</div> </div>
<div className="sm:col-span-2 flex flex-col justify-between items-end gap-3"> <div className="sm:col-span-2 flex flex-col justify-between items-end gap-3">
<div className="bg-slate-50 p-3 rounded-xl border border-slate-100 text-slate-600 font-medium w-full text-[11px] leading-relaxed"> <div className="bg-slate-50 p-3 rounded-xl border border-slate-100 text-slate-600 font-medium w-full text-[11px] leading-relaxed">
@ -141,16 +366,10 @@ export default function SafetyHub({ reports, rules, moderation_queue }) {
) : item.content} ) : item.content}
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<button <button className="px-3.5 py-1.5 bg-emerald-600 hover:bg-emerald-500 text-white rounded-lg text-[9px] font-black uppercase tracking-wider transition-colors shadow-sm">
onClick={() => handleModQueue(item.id, 'approve')}
className="px-3.5 py-1.5 bg-emerald-600 hover:bg-emerald-500 text-white rounded-lg text-[9px] font-black uppercase tracking-wider"
>
Approve Content Approve Content
</button> </button>
<button <button className="px-3.5 py-1.5 bg-red-600 hover:bg-red-500 text-white rounded-lg text-[9px] font-black uppercase tracking-wider transition-colors shadow-sm">
onClick={() => handleModQueue(item.id, 'reject')}
className="px-3.5 py-1.5 bg-red-600 hover:bg-red-500 text-white rounded-lg text-[9px] font-black uppercase tracking-wider"
>
Reject / Delete Reject / Delete
</button> </button>
</div> </div>
@ -160,11 +379,9 @@ export default function SafetyHub({ reports, rules, moderation_queue }) {
</div> </div>
</div> </div>
</div> {/* Auto-Detection Rules */}
<div className="bg-white p-5 border border-slate-200 rounded-2xl shadow-sm flex flex-col justify-between">
{/* Column 3: Auto-Flag Detection Rules */} <div className="space-y-4">
<div className="space-y-6">
<div className="bg-white p-5 border border-slate-200 rounded-2xl shadow-sm space-y-4">
<div> <div>
<h3 className="text-sm font-black text-slate-800 uppercase tracking-widest">Auto-Detection Rules</h3> <h3 className="text-sm font-black text-slate-800 uppercase tracking-widest">Auto-Detection Rules</h3>
<p className="text-[10px] text-slate-400 mt-0.5">Parameters triggering system automated flags</p> <p className="text-[10px] text-slate-400 mt-0.5">Parameters triggering system automated flags</p>
@ -183,30 +400,21 @@ export default function SafetyHub({ reports, rules, moderation_queue }) {
</div> </div>
))} ))}
</div> </div>
</div>
<button className="w-full py-3 bg-[#0F6E56] text-white rounded-xl text-[10px] font-black uppercase tracking-widest shadow-md hover:bg-[#085041] flex items-center justify-center gap-1.5"> <button className="w-full mt-4 py-3 bg-[#0F6E56] text-white rounded-xl text-[10px] font-black uppercase tracking-widest shadow-md hover:bg-[#085041] flex items-center justify-center gap-1.5 transition-colors">
<Plus className="w-3.5 h-3.5" /> <Plus className="w-3.5 h-3.5" />
<span>Create Custom Rule</span> <span>Create Custom Rule</span>
</button> </button>
</div> </div>
{/* Keyword Abuse escalating info */}
<div className="bg-amber-50/50 p-5 border border-amber-200 rounded-2xl shadow-sm space-y-3">
<span className="text-[10px] font-black text-amber-800 uppercase tracking-widest flex items-center gap-1">
<ShieldAlert className="w-4 h-4" />
<span>Direct Chat Abuse Triggered Logs</span>
</span>
<p className="text-xs text-slate-600 font-semibold leading-relaxed">System triggered 14 keyword warning notices to employers for sharing email/phone details in chats prior to hiring offer completions today.</p>
</div>
</div>
</div> </div>
</div> </div>
{/* Investigation Dialog Modal */} {/* Investigation & Action Dialog Modal */}
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}> <Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent className="max-w-xl bg-white p-6 rounded-[24px] border-none shadow-2xl font-sans"> <DialogContent className="max-w-xl bg-white p-6 rounded-[24px] border-none shadow-2xl font-sans">
<DialogHeader className="mb-4"> <DialogHeader className="mb-4">
<DialogTitle className="text-lg font-black text-slate-800 flex items-center gap-1"> <DialogTitle className="text-lg font-black text-slate-800 flex items-center gap-2">
<ShieldAlert className="w-5 h-5 text-red-600" /> <ShieldAlert className="w-5 h-5 text-red-600" />
<span>Incident Investigation: <span className="font-mono text-slate-400">{selectedReport?.id}</span></span> <span>Incident Investigation: <span className="font-mono text-slate-400">{selectedReport?.id}</span></span>
</DialogTitle> </DialogTitle>
@ -215,18 +423,20 @@ export default function SafetyHub({ reports, rules, moderation_queue }) {
<form onSubmit={handleResolve} className="space-y-4 text-xs font-bold text-slate-700"> <form onSubmit={handleResolve} className="space-y-4 text-xs font-bold text-slate-700">
<div className="space-y-1"> <div className="space-y-1">
<label className="text-[9px] font-black text-slate-400 uppercase tracking-widest">Reason / Incident Subject</label> <label className="text-[9px] font-black text-slate-400 uppercase tracking-widest">Reason / Incident Subject</label>
<div className="p-3 bg-slate-50 border rounded-xl font-black text-slate-800">{selectedReport?.reason}</div> <div className="p-3 bg-slate-50 border border-slate-200 rounded-xl font-black text-slate-800">{selectedReport?.reason}</div>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<label className="text-[9px] font-black text-slate-400 uppercase tracking-widest">Report Description</label> <label className="text-[9px] font-black text-slate-400 uppercase tracking-widest">Report Description</label>
<p className="p-3 bg-slate-50 border rounded-xl font-medium text-slate-600 leading-relaxed">{selectedReport?.description}</p> <p className="p-3 bg-slate-50 border border-slate-200 rounded-xl font-medium text-slate-600 leading-relaxed">
{selectedReport?.description || 'No description details provided.'}
</p>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<label className="text-[9px] font-black text-slate-400 uppercase tracking-widest">Select Resolution Action</label> <label className="text-[9px] font-black text-slate-400 uppercase tracking-widest">Select Resolution Action</label>
<select <select
className="w-full bg-slate-50 border rounded-xl px-3 py-2.5 font-bold outline-none cursor-pointer focus:bg-white" className="w-full bg-slate-50 border border-slate-200 rounded-xl px-3 py-2.5 font-bold outline-none cursor-pointer focus:bg-white transition-colors"
value={resolutionAction} value={resolutionAction}
onChange={e => setResolutionAction(e.target.value)} onChange={e => setResolutionAction(e.target.value)}
> >
@ -242,7 +452,7 @@ export default function SafetyHub({ reports, rules, moderation_queue }) {
<textarea <textarea
rows="3" rows="3"
placeholder="State findings of direct message logs audits or phone outreach validation..." placeholder="State findings of direct message logs audits or phone outreach validation..."
className="w-full bg-white border rounded-xl p-3 font-semibold focus:ring-2 focus:ring-[#0F6E56]/10 outline-none" className="w-full bg-white border border-slate-200 rounded-xl p-3 font-semibold focus:ring-2 focus:ring-[#0F6E56]/10 outline-none"
value={adminNotes} value={adminNotes}
onChange={e => setAdminNotes(e.target.value)} onChange={e => setAdminNotes(e.target.value)}
/> />
@ -251,14 +461,14 @@ export default function SafetyHub({ reports, rules, moderation_queue }) {
<div className="flex gap-2 pt-2 justify-end"> <div className="flex gap-2 pt-2 justify-end">
<button <button
type="submit" type="submit"
className="px-5 py-2.5 bg-slate-900 hover:bg-slate-800 text-white rounded-xl text-[10px] font-black uppercase tracking-widest" className="px-5 py-2.5 bg-[#0F6E56] hover:bg-[#085041] text-white rounded-xl text-[10px] font-black uppercase tracking-widest transition-colors shadow-md"
> >
Submit Findings Submit Findings
</button> </button>
<button <button
type="button" type="button"
onClick={() => setIsDialogOpen(false)} onClick={() => setIsDialogOpen(false)}
className="px-4 py-2.5 bg-slate-100 hover:bg-slate-200 text-slate-700 rounded-xl text-[10px] font-black uppercase tracking-widest" className="px-4 py-2.5 bg-slate-100 hover:bg-slate-200 text-slate-700 rounded-xl text-[10px] font-black uppercase tracking-widest transition-colors"
> >
Close Close
</button> </button>

View File

@ -2,21 +2,15 @@ import React, { useState } from 'react';
import { Head, router } from '@inertiajs/react'; import { Head, router } from '@inertiajs/react';
import AdminLayout from '@/Layouts/AdminLayout'; import AdminLayout from '@/Layouts/AdminLayout';
import { import {
Search,
FileText,
Eye,
CheckCircle, CheckCircle,
XCircle, XCircle,
Clock, Clock,
Edit2,
ZoomIn,
X, X,
AlertTriangle, AlertTriangle,
ShieldCheck, ShieldCheck,
Edit3, Edit3,
History, History,
Sparkles, Sparkles
CheckSquare
} from 'lucide-react'; } from 'lucide-react';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { import {
@ -29,7 +23,6 @@ import {
export default function Verifications({ verifications, status }) { export default function Verifications({ verifications, status }) {
const [selectedItem, setSelectedItem] = useState(null); const [selectedItem, setSelectedItem] = useState(null);
const [isDialogOpen, setIsDialogOpen] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false);
const [lightboxImage, setLightboxImage] = useState(null);
// Vetting form overrides // Vetting form overrides
const [ocrOverrides, setOcrOverrides] = useState({}); const [ocrOverrides, setOcrOverrides] = useState({});
@ -37,7 +30,7 @@ export default function Verifications({ verifications, status }) {
const [rejectionReason, setRejectionReason] = useState(''); const [rejectionReason, setRejectionReason] = useState('');
const [showRejectionForm, setShowRejectionForm] = useState(false); const [showRejectionForm, setShowRejectionForm] = useState(false);
// Local mock verification queue containing OCR score mismatches // Local queue of workers to verify
const localQueue = [ const localQueue = [
{ {
id: 101, id: 101,
@ -47,12 +40,9 @@ export default function Verifications({ verifications, status }) {
processed_at: '2026-05-23 14:15', processed_at: '2026-05-23 14:15',
status: 'approved', status: 'approved',
confidence: 98, confidence: 98,
verification_method: 'Auto-OCR', verification_method: 'Auto',
document_type: 'Passport & Visa Scan', document_type: 'Passport',
warnings: [], warnings: [],
document_images: [
'https://images.unsplash.com/photo-1544717305-2782549b5136?q=80&w=600&auto=format&fit=crop',
],
ocr_data: { ocr_data: {
'Name': 'Fatima Zahra', 'Name': 'Fatima Zahra',
'DOB': '1992-08-14', 'DOB': '1992-08-14',
@ -69,12 +59,9 @@ export default function Verifications({ verifications, status }) {
processed_at: '2026-05-23 11:30', processed_at: '2026-05-23 11:30',
status: 'flagged', status: 'flagged',
confidence: 58, confidence: 58,
verification_method: 'Auto-OCR', verification_method: 'Auto',
document_type: 'Passport Scan', document_type: 'Passport',
warnings: ['Blurry text region', 'Signature discrepancy flagged'], warnings: ['Blurry text region', 'Signature discrepancy flagged'],
document_images: [
'https://images.unsplash.com/photo-1580489944761-15a19d654956?q=80&w=600&auto=format&fit=crop'
],
ocr_data: { ocr_data: {
'Name': 'Amina Diop', 'Name': 'Amina Diop',
'DOB': '1994-12-01', 'DOB': '1994-12-01',
@ -91,12 +78,9 @@ export default function Verifications({ verifications, status }) {
processed_at: '2026-05-22 16:40', processed_at: '2026-05-22 16:40',
status: 'flagged', status: 'flagged',
confidence: 62, confidence: 62,
verification_method: 'Auto-OCR', verification_method: 'Auto',
document_type: 'Visa scan details', document_type: 'Visa scan details',
warnings: ['Document expiration alert (Expires in 5 days)'], warnings: ['Document expiration alert (Expires in 5 days)'],
document_images: [
'https://images.unsplash.com/photo-1534528741775-53994a69daeb?q=80&w=600&auto=format&fit=crop'
],
ocr_data: { ocr_data: {
'Name': 'Siti Aminah', 'Name': 'Siti Aminah',
'DOB': '1988-03-25', 'DOB': '1988-03-25',
@ -135,24 +119,24 @@ export default function Verifications({ verifications, status }) {
}; };
return ( return (
<AdminLayout title="Vetting & OCR Review Hub"> <AdminLayout title="Verifications">
<Head title="OCR Verifications" /> <Head title="Verifications" />
<div className="font-sans max-w-7xl mx-auto space-y-6"> <div className="font-sans max-w-7xl mx-auto space-y-6 pb-12">
{/* Header overview and status */} {/* Simplified Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 border-b border-slate-200 pb-5">
<div> <div>
<h1 className="text-xl font-bold text-gray-900 tracking-tight">OCR Vetting Queue & Verification System</h1> <h1 className="text-xl font-black text-slate-800 uppercase tracking-tight">Worker Verifications</h1>
<p className="text-xs text-gray-500 mt-0.5">High-confidence auto-approvals, low-confidence warning audits, and inline OCR override triggers.</p> <p className="text-xs text-slate-500 mt-0.5 font-medium">Verify workers, check passports, and override data inputs.</p>
</div> </div>
{/* Filter Tabs */} {/* Filter Tabs */}
<div className="flex bg-slate-200/60 p-1 rounded-xl w-fit border border-slate-200 font-sans"> <div className="flex bg-slate-100 p-1 rounded-xl w-fit border border-slate-200">
{[ {[
{ label: 'All Audits', value: 'all' }, { label: 'All', value: 'all' },
{ label: 'Auto-Approved (High Confidence)', value: 'approved' }, { label: 'Approved', value: 'approved' },
{ label: 'Vetting Flags (Low Confidence)', value: 'rejected' }, { label: 'Flagged', value: 'rejected' },
].map((tab) => ( ].map((tab) => (
<button <button
key={tab.value} key={tab.value}
@ -161,7 +145,7 @@ export default function Verifications({ verifications, status }) {
className={`px-4 py-1.5 rounded-lg text-[10px] font-black uppercase tracking-wider transition-all ${ className={`px-4 py-1.5 rounded-lg text-[10px] font-black uppercase tracking-wider transition-all ${
(status || 'all') === tab.value (status || 'all') === tab.value
? 'bg-white text-[#0F6E56] shadow-sm' ? 'bg-white text-[#0F6E56] shadow-sm'
: 'text-slate-600 hover:text-slate-900' : 'text-slate-650 hover:text-slate-900'
}`} }`}
> >
{tab.label} {tab.label}
@ -174,43 +158,43 @@ export default function Verifications({ verifications, status }) {
<div className="grid grid-cols-1 md:grid-cols-3 gap-6"> <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="bg-white p-5 border border-slate-200 rounded-2xl flex items-center justify-between shadow-sm"> <div className="bg-white p-5 border border-slate-200 rounded-2xl flex items-center justify-between shadow-sm">
<div> <div>
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest block">System Auto-Approve Rate</span> <span className="text-[10px] font-black text-slate-400 uppercase tracking-widest block">Auto-Approve Rate</span>
<span className="text-2xl font-black text-slate-800">92.4%</span> <span className="text-2xl font-black text-slate-800 mt-1">92.4%</span>
</div> </div>
<div className="w-10 h-10 bg-emerald-50 rounded-xl flex items-center justify-center"> <div className="w-10 h-10 bg-emerald-50 rounded-xl flex items-center justify-center text-emerald-600">
<ShieldCheck className="w-5 h-5 text-emerald-600" /> <ShieldCheck className="w-5 h-5" />
</div> </div>
</div> </div>
<div className="bg-white p-5 border border-slate-200 rounded-2xl flex items-center justify-between shadow-sm"> <div className="bg-white p-5 border border-slate-200 rounded-2xl flex items-center justify-between shadow-sm">
<div> <div>
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest block">OCR Average Confidence Score</span> <span className="text-[10px] font-black text-slate-400 uppercase tracking-widest block">Average Match Score</span>
<span className="text-2xl font-black text-blue-600">91.8%</span> <span className="text-2xl font-black text-blue-600 mt-1">91.8%</span>
</div> </div>
<div className="w-10 h-10 bg-blue-50 rounded-xl flex items-center justify-center"> <div className="w-10 h-10 bg-blue-50 rounded-xl flex items-center justify-center text-blue-600">
<Sparkles className="w-5 h-5 text-blue-600" /> <Sparkles className="w-5 h-5" />
</div> </div>
</div> </div>
<div className="bg-white p-5 border border-slate-200 rounded-2xl flex items-center justify-between shadow-sm"> <div className="bg-white p-5 border border-slate-200 rounded-2xl flex items-center justify-between shadow-sm">
<div> <div>
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest block">Vetting Queue Flags</span> <span className="text-[10px] font-black text-slate-400 uppercase tracking-widest block">Flagged Workers</span>
<span className="text-2xl font-black text-amber-600">2 Pending review</span> <span className="text-2xl font-black text-amber-600 mt-1">2 Pending</span>
</div> </div>
<div className="w-10 h-10 bg-amber-50 rounded-xl flex items-center justify-center"> <div className="w-10 h-10 bg-amber-50 rounded-xl flex items-center justify-center text-amber-600">
<AlertTriangle className="w-5 h-5 text-amber-600" /> <AlertTriangle className="w-5 h-5" />
</div> </div>
</div> </div>
</div> </div>
{/* Verification Queue Datagrid */} {/* Verification Queue Datagrid */}
<div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden"> <div className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden">
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-left border-collapse"> <table className="w-full text-left border-collapse">
<thead> <thead>
<tr className="bg-slate-50/70 border-b border-slate-100 text-[10px] font-black text-slate-500 uppercase tracking-widest"> <tr className="bg-slate-50/70 border-b border-slate-150 text-[10px] font-black text-slate-500 uppercase tracking-widest">
<th className="py-4 px-6">Worker Name</th> <th className="py-4 px-6">Worker Name</th>
<th className="py-4 px-6">Passport ID</th> <th className="py-4 px-6">Passport ID</th>
<th className="py-4 px-6">OCR Confidence Score</th> <th className="py-4 px-6">Confidence Score</th>
<th className="py-4 px-6">Vetting Warnings</th> <th className="py-4 px-6">Warnings</th>
<th className="py-4 px-6">Processed Date</th> <th className="py-4 px-6">Processed Date</th>
<th className="py-4 px-6 text-right">Action</th> <th className="py-4 px-6 text-right">Action</th>
</tr> </tr>
@ -221,13 +205,13 @@ export default function Verifications({ verifications, status }) {
<td className="py-4 px-6"> <td className="py-4 px-6">
<div> <div>
<div className="text-sm font-black text-slate-900">{item.worker_name}</div> <div className="text-sm font-black text-slate-900">{item.worker_name}</div>
<div className="text-[10px] text-slate-400 font-semibold">{item.nationality} {item.document_type}</div> <div className="text-[10px] text-slate-400 font-semibold mt-0.5">{item.nationality} {item.document_type}</div>
</div> </div>
</td> </td>
<td className="py-4 px-6 font-mono font-medium">{item.passport_number}</td> <td className="py-4 px-6 font-mono font-semibold text-slate-650">{item.passport_number}</td>
<td className="py-4 px-6"> <td className="py-4 px-6">
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<div className="w-24 bg-slate-100 rounded-full h-2.5 overflow-hidden"> <div className="w-24 bg-slate-100 rounded-full h-2 overflow-hidden">
<div <div
className={`h-full rounded-full ${ className={`h-full rounded-full ${
item.confidence > 90 ? 'bg-emerald-500' : item.confidence > 90 ? 'bg-emerald-500' :
@ -244,15 +228,15 @@ export default function Verifications({ verifications, status }) {
</td> </td>
<td className="py-4 px-6"> <td className="py-4 px-6">
{item.warnings.length > 0 ? ( {item.warnings.length > 0 ? (
<div className="space-y-1"> <div className="flex flex-wrap gap-1">
{item.warnings.map((w, idx) => ( {item.warnings.map((w, idx) => (
<span key={idx} className="inline-flex items-center bg-red-50 text-red-700 text-[9px] px-2 py-0.5 rounded-full uppercase"> <span key={idx} className="inline-flex items-center bg-red-50 text-red-700 text-[9px] px-2.5 py-0.5 rounded-full uppercase font-black tracking-wide border border-red-100">
<AlertTriangle className="w-2.5 h-2.5 mr-1" /> {w} {w}
</span> </span>
))} ))}
</div> </div>
) : ( ) : (
<span className="text-[10px] font-black text-emerald-600 uppercase">None (Safe)</span> <span className="text-[10px] font-black text-emerald-600 uppercase">None</span>
)} )}
</td> </td>
<td className="py-4 px-6 text-slate-400 font-semibold">{item.processed_at}</td> <td className="py-4 px-6 text-slate-400 font-semibold">{item.processed_at}</td>
@ -262,11 +246,11 @@ export default function Verifications({ verifications, status }) {
onClick={() => openReview(item)} onClick={() => openReview(item)}
className={`inline-flex items-center px-4 py-2 border rounded-xl text-[10px] font-black uppercase tracking-wider transition-colors focus:outline-none ${ className={`inline-flex items-center px-4 py-2 border rounded-xl text-[10px] font-black uppercase tracking-wider transition-colors focus:outline-none ${
item.status === 'flagged' item.status === 'flagged'
? 'bg-amber-500 text-white border-none shadow-md shadow-amber-500/20 hover:bg-amber-600' ? 'bg-amber-500 text-white border-none shadow-md hover:bg-amber-600'
: 'bg-white hover:bg-slate-50 text-slate-700 border-slate-200' : 'bg-white hover:bg-slate-50 text-slate-700 border-slate-200'
}`} }`}
> >
{item.status === 'flagged' ? 'Verify OCR' : 'Audit Log'} {item.status === 'flagged' ? 'Verify' : 'Logs'}
</button> </button>
</td> </td>
</tr> </tr>
@ -277,97 +261,54 @@ export default function Verifications({ verifications, status }) {
</div> </div>
{/* Audit logs details grid */} {/* Audit logs details grid */}
<div className="bg-slate-50 rounded-2xl p-5 border border-slate-200"> <div className="bg-white rounded-2xl p-6 border border-slate-200 shadow-sm space-y-4">
<h3 className="text-xs font-black text-slate-800 uppercase tracking-widest flex items-center gap-1.5 mb-3"> <h3 className="text-xs font-black text-slate-800 uppercase tracking-widest flex items-center gap-1.5 border-b border-slate-100 pb-3">
<History className="w-4 h-4 text-slate-600" /> <History className="w-4 h-4 text-slate-600" />
<span>OCR Vetting Action Audit Logs</span> <span>Verification History</span>
</h3> </h3>
<div className="space-y-2"> <div className="space-y-2">
{[ {[
{ time: '2026-05-23 15:20', text: 'Admin Vetting Officer manually approved passport scan verification for Leila Bekri', ip: '192.168.1.5' }, { time: '2026-05-23 15:20', text: 'Admin manual approval completed for Leila Bekri passport verification', ip: '192.168.1.5' },
{ time: '2026-05-23 14:15', text: 'System OCR Passport auto-verification completed successfully for Fatima Zahra', ip: 'Auto-System' }, { time: '2026-05-23 14:15', text: 'Auto passport verification completed successfully for Fatima Zahra', ip: 'Auto-System' },
{ time: '2026-05-23 13:42', text: 'System flagged passport scan of Grace Omondi due to blurred signature signature check failure', ip: 'Auto-System' }, { time: '2026-05-23 13:42', text: 'Auto verification flagged passport scan of Grace Omondi due to blurred signature scan', ip: 'Auto-System' },
].map((log, idx) => ( ].map((log, idx) => (
<div key={idx} className="bg-white p-3 rounded-xl border border-slate-100 flex items-center justify-between text-xs font-bold text-slate-700 shadow-sm"> <div key={idx} className="bg-slate-50 p-3.5 rounded-xl border border-slate-100 flex items-center justify-between text-xs font-bold text-slate-700 shadow-inner">
<div className="flex items-center space-x-3"> <div className="flex items-center space-x-3">
<Clock className="w-4 h-4 text-slate-400" /> <Clock className="w-4 h-4 text-slate-400" />
<span>{log.text}</span> <span>{log.text}</span>
</div> </div>
<span className="text-[10px] text-slate-400">{log.time} IP: {log.ip}</span> <span className="text-[10px] text-slate-400 font-semibold">{log.time} IP: {log.ip}</span>
</div> </div>
))} ))}
</div> </div>
</div> </div>
</div> </div>
{/* Vetting Vetting Audit Dialog */} {/* Verification Audit Dialog - Simplified & Width Optimized */}
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}> <Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<DialogContent className="sm:max-w-4xl w-full bg-white p-0 overflow-hidden font-sans rounded-[24px] border-none shadow-2xl max-h-[90vh] flex flex-col"> <DialogContent className="sm:max-w-xl w-full bg-white p-0 overflow-hidden font-sans rounded-[24px] border-none shadow-2xl max-h-[90vh] flex flex-col">
<DialogHeader className="p-6 border-b border-slate-100 bg-[#0f6e56]/5 flex-shrink-0"> <DialogHeader className="p-6 border-b border-slate-100 bg-[#0f6e56]/5 flex-shrink-0">
<DialogTitle className="text-lg font-black text-slate-800 flex items-center justify-between tracking-tight"> <DialogTitle className="text-lg font-black text-slate-800 flex items-center justify-between tracking-tight">
<span className="flex items-center gap-2"> <span className="flex items-center gap-2">
<ShieldCheck className="w-5 h-5 text-[#0F6E56]" /> <ShieldCheck className="w-5 h-5 text-[#0F6E56]" />
<span>Vetting Manual Audit: <span className="text-[#0F6E56]">{selectedItem?.worker_name}</span></span> <span>Verify Worker: <span className="text-[#0F6E56]">{selectedItem?.worker_name}</span></span>
</span> </span>
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Badge className={`border-none px-3.5 py-1.5 font-black text-[9px] uppercase tracking-wider rounded-lg shadow-sm flex items-center gap-1 ${ <Badge className="border-none bg-[#0F6E56] text-white px-3.5 py-1.5 font-black text-[9px] uppercase tracking-wider rounded-lg shadow-sm">
selectedItem?.confidence > 80 <span>Score: {selectedItem?.confidence}%</span>
? 'bg-emerald-500 text-white shadow-emerald-500/20'
: 'bg-amber-500 text-white shadow-amber-500/20'
}`}>
<Sparkles className="w-3.5 h-3.5 animate-pulse" />
<span>OCR Confidence: {selectedItem?.confidence}%</span>
</Badge> </Badge>
</div> </div>
</DialogTitle> </DialogTitle>
</DialogHeader> </DialogHeader>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 p-6 overflow-y-auto flex-1 bg-slate-50/30"> <div className="p-6 overflow-y-auto flex-1 bg-slate-50/30 space-y-6">
{/* Scanned proof & Warnings Column */} {/* Warnings Box - Rendered Inline */}
<div className="space-y-5"> {selectedItem?.warnings?.length > 0 && (
<div className="bg-white p-4 rounded-2xl border border-slate-200 shadow-sm space-y-3">
<h3 className="text-xs font-black text-slate-700 uppercase tracking-widest flex items-center gap-1.5">
<FileText className="w-4 h-4 text-[#0F6E56]" />
<span>Scanned Passport Scan Document</span>
</h3>
{selectedItem?.document_type?.toLowerCase().includes('emirates') || selectedItem?.document_type?.toLowerCase().includes('eid') || selectedItem?.document_type?.toLowerCase().includes('id') ? (
<div className="relative rounded-xl overflow-hidden border border-slate-200 bg-slate-950 flex flex-col items-center justify-center p-6 text-center aspect-[4/3] max-h-72 shadow-inner">
<ShieldCheck className="w-12 h-12 text-emerald-500 mb-3 animate-pulse" />
<div className="text-xs font-black text-white uppercase tracking-wider">Raw Scan Deleted (PDPL Law)</div>
<div className="text-[10px] text-slate-400 mt-2 font-medium leading-relaxed">
To comply strictly with UAE Personal Data Protection Law (PDPL), raw Emirates ID images are immediately destroyed post-OCR. Vetted metadata has been securely preserved.
</div>
<div className="mt-4 bg-emerald-600 text-white font-black text-[8px] tracking-wider uppercase border-none px-2.5 py-1 rounded">
[DELETED_FOR_PDPL_COMPLIANCE]
</div>
</div>
) : (
<div
onClick={() => setLightboxImage(selectedItem?.document_images[0])}
className="relative rounded-xl overflow-hidden border border-slate-200 bg-slate-100 cursor-zoom-in shadow-inner aspect-[4/3] max-h-72 group"
>
<img
src={selectedItem?.document_images[0]}
alt="Verification Scan"
className="w-full h-full object-cover group-hover:scale-102 transition-transform duration-300"
/>
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center text-white text-xs font-black uppercase tracking-wider backdrop-blur-[1px]">
<ZoomIn className="w-4.5 h-4.5 mr-1" /> Click to Zoom Document
</div>
</div>
)}
<p className="text-[10px] text-slate-400 font-bold text-center uppercase tracking-wide">
Type: {selectedItem?.document_type}
</p>
</div>
{/* Warnings Box */}
{selectedItem?.warnings?.length > 0 ? (
<div className="bg-rose-50/50 border border-rose-100 p-5 rounded-2xl space-y-3 shadow-sm"> <div className="bg-rose-50/50 border border-rose-100 p-5 rounded-2xl space-y-3 shadow-sm">
<span className="text-[10px] font-black text-rose-700 uppercase tracking-widest flex items-center gap-1.5"> <span className="text-[10px] font-black text-rose-700 uppercase tracking-widest flex items-center gap-1.5">
<AlertTriangle className="w-4 h-4 text-rose-600 animate-bounce" /> <AlertTriangle className="w-4 h-4 text-rose-600 animate-bounce" />
<span>Suspicious Document Vetting Warning</span> <span>Verification Warnings</span>
</span> </span>
<ul className="space-y-2 text-xs font-bold text-rose-950"> <ul className="space-y-2 text-xs font-bold text-rose-950">
{selectedItem.warnings.map((w, idx) => ( {selectedItem.warnings.map((w, idx) => (
@ -378,16 +319,10 @@ export default function Verifications({ verifications, status }) {
))} ))}
</ul> </ul>
</div> </div>
) : (
<div className="bg-emerald-50/30 border border-emerald-100 p-4 rounded-2xl flex items-center space-x-2 text-emerald-800 text-xs font-bold">
<CheckCircle className="w-4 h-4 text-emerald-600" />
<span>Vetting passed checks. System matches signature algorithms perfectly.</span>
</div>
)} )}
</div>
{/* OCR Data Extraction Form Column - Supports editing */} {/* OCR Data Extraction Form - Styled Cleanly */}
<div className="bg-white p-5 border border-slate-200 rounded-2xl shadow-sm space-y-6 flex flex-col justify-between"> <div className="bg-white p-5 border border-slate-200 rounded-2xl shadow-sm space-y-6">
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between border-b border-slate-100 pb-2"> <div className="flex items-center justify-between border-b border-slate-100 pb-2">
<h3 className="text-xs font-black text-slate-700 uppercase tracking-widest">Extracted Text Vitals</h3> <h3 className="text-xs font-black text-slate-700 uppercase tracking-widest">Extracted Text Vitals</h3>
@ -396,18 +331,18 @@ export default function Verifications({ verifications, status }) {
className="text-xs text-[#0F6E56] hover:text-[#085041] font-black flex items-center gap-1 uppercase tracking-wider text-[10px]" className="text-xs text-[#0F6E56] hover:text-[#085041] font-black flex items-center gap-1 uppercase tracking-wider text-[10px]"
> >
<Edit3 className="w-3.5 h-3.5" /> <Edit3 className="w-3.5 h-3.5" />
<span>{isEditingOcr ? 'Cancel Edit' : 'Edit OCR Values'}</span> <span>{isEditingOcr ? 'Cancel' : 'Edit values'}</span>
</button> </button>
</div> </div>
<div className="space-y-4"> <div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{Object.entries(ocrOverrides).map(([key, value]) => ( {Object.entries(ocrOverrides).map(([key, value]) => (
<div key={key} className="space-y-1.5"> <div key={key} className="space-y-1.5">
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest block">{key}</label> <label className="text-[10px] font-black text-slate-400 uppercase tracking-widest block">{key}</label>
{isEditingOcr ? ( {isEditingOcr ? (
<input <input
type="text" type="text"
className="w-full bg-slate-50 border border-slate-200 rounded-xl px-3.5 py-2.5 text-xs font-bold focus:bg-white outline-none focus:ring-4 focus:ring-[#0F6E56]/10 focus:border-[#0F6E56] transition-all" className="w-full bg-slate-50 border border-slate-200 rounded-xl px-3.5 py-2.5 text-xs font-bold focus:bg-white outline-none focus:ring-2 focus:ring-[#0F6E56]/10 focus:border-[#0F6E56] transition-all"
value={value} value={value}
onChange={e => { onChange={e => {
const newVal = e.target.value; const newVal = e.target.value;
@ -427,11 +362,11 @@ export default function Verifications({ verifications, status }) {
{/* Manual Rejection reason input */} {/* Manual Rejection reason input */}
{showRejectionForm ? ( {showRejectionForm ? (
<div className="bg-rose-50/30 p-4 border border-rose-100 rounded-xl space-y-3"> <div className="bg-rose-50/30 p-4 border border-rose-100 rounded-xl space-y-3">
<label className="text-[10px] font-black text-rose-700 uppercase tracking-widest block">Rejection compliance reason</label> <label className="text-[10px] font-black text-rose-700 uppercase tracking-widest block">Reason for Rejection</label>
<textarea <textarea
rows="2" rows="2"
className="w-full bg-white border border-rose-200 rounded-xl p-3 text-xs font-bold focus:ring-4 focus:ring-rose-500/10 outline-none transition-all" className="w-full bg-white border border-rose-200 rounded-xl p-3 text-xs font-bold focus:ring-4 focus:ring-rose-500/10 outline-none transition-all"
placeholder="Explain to worker what document error occurred (e.g., expiry date mismatch, blurred image)..." placeholder="Explain verification error (e.g. invalid document)..."
value={rejectionReason} value={rejectionReason}
onChange={e => setRejectionReason(e.target.value)} onChange={e => setRejectionReason(e.target.value)}
/> />
@ -440,11 +375,11 @@ export default function Verifications({ verifications, status }) {
onClick={() => handleVerifySubmit('reject')} onClick={() => handleVerifySubmit('reject')}
className="px-4 py-2.5 bg-red-600 hover:bg-red-700 text-white rounded-xl text-[10px] font-black uppercase tracking-widest shadow-md transition-colors" className="px-4 py-2.5 bg-red-600 hover:bg-red-700 text-white rounded-xl text-[10px] font-black uppercase tracking-widest shadow-md transition-colors"
> >
Confirm Rejection Confirm
</button> </button>
<button <button
onClick={() => setShowRejectionForm(false)} onClick={() => setShowRejectionForm(false)}
className="px-4 py-2.5 bg-slate-200 text-slate-600 hover:bg-slate-300 rounded-xl text-[10px] font-black uppercase tracking-widest transition-colors" className="px-4 py-2.5 bg-slate-200 text-slate-650 hover:bg-slate-350 rounded-xl text-[10px] font-black uppercase tracking-widest transition-colors"
> >
Cancel Cancel
</button> </button>
@ -454,17 +389,17 @@ export default function Verifications({ verifications, status }) {
<div className="flex gap-3 pt-4 border-t border-slate-100 flex-shrink-0"> <div className="flex gap-3 pt-4 border-t border-slate-100 flex-shrink-0">
<button <button
onClick={() => handleVerifySubmit('approve')} onClick={() => handleVerifySubmit('approve')}
className="flex-1 py-3 bg-[#0F6E56] text-white rounded-xl text-[10px] font-black uppercase tracking-widest shadow-lg shadow-teal-700/20 hover:bg-[#085041] transition-all flex items-center justify-center gap-1" className="flex-1 py-3 bg-[#0F6E56] text-white rounded-xl text-[10px] font-black uppercase tracking-widest shadow-lg hover:bg-[#085041] transition-all flex items-center justify-center gap-1"
> >
<CheckCircle className="w-4 h-4" /> <CheckCircle className="w-4 h-4" />
<span>Approve Verification</span> <span>Approve</span>
</button> </button>
<button <button
onClick={() => setShowRejectionForm(true)} onClick={() => setShowRejectionForm(true)}
className="flex-1 py-3 bg-red-600 text-white rounded-xl text-[10px] font-black uppercase tracking-widest shadow-lg shadow-red-600/10 hover:bg-red-700 transition-all flex items-center justify-center gap-1" className="flex-1 py-3 bg-red-600 text-white rounded-xl text-[10px] font-black uppercase tracking-widest shadow-lg hover:bg-red-700 transition-all flex items-center justify-center gap-1"
> >
<XCircle className="w-4 h-4" /> <XCircle className="w-4 h-4" />
<span>Flag Rejection</span> <span>Reject</span>
</button> </button>
</div> </div>
)} )}
@ -472,26 +407,6 @@ export default function Verifications({ verifications, status }) {
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{/* Lightbox Zoom */}
{lightboxImage && (
<div
onClick={() => setLightboxImage(null)}
className="fixed inset-0 z-50 bg-black/80 backdrop-blur-sm flex items-center justify-center p-4"
>
<button
type="button"
className="absolute top-6 right-6 text-white/80 hover:text-white p-2 bg-white/10 rounded-full transition-all focus:outline-none"
>
<X className="w-6 h-6" />
</button>
<img
src={lightboxImage}
alt="Zoomed scan"
className="max-w-full max-h-[90vh] object-contain rounded-xl shadow-2xl border border-white/20"
/>
</div>
)}
</AdminLayout> </AdminLayout>
); );
} }

View File

@ -77,6 +77,8 @@ export default function Show({ conversation, initialMessages, conversations = []
setMessages(prev => [...prev, newMsg]); setMessages(prev => [...prev, newMsg]);
setInput(''); setInput('');
const isLookingJobQuery = text.toLowerCase().includes('looking') && text.toLowerCase().includes('job');
router.post(`/employer/messages/${conversation.id}/send`, { router.post(`/employer/messages/${conversation.id}/send`, {
text: text text: text
}, { }, {
@ -93,19 +95,24 @@ export default function Show({ conversation, initialMessages, conversations = []
setIsTyping(true); setIsTyping(true);
setTimeout(() => { setTimeout(() => {
setIsTyping(false); setIsTyping(false);
const autoReply = {
id: Date.now() + 1,
sender: 'worker',
text: t('auto_reply_text', "Thank you for your response! I have reviewed your offer proposal and am looking forward to our video interview."),
time: t('just_now', 'Just Now')
};
setMessages(prev => [...prev, autoReply]);
// Pop up a clear message received notification for the sponsor // Reload conversation to pull actual database messages (including automated worker 'Yes')
router.reload({
preserveScroll: true,
onSuccess: () => {
if (isLookingJobQuery) {
toast.success(t('candidate_hired_success', "Candidate response: YES! Automatically marked as HIRED!"), {
description: t('candidate_hired_desc', "{name} has been successfully added to your Hired candidate pipeline.").replace('{name}', conversation.worker_name),
duration: 6000,
});
} else {
toast.info(t('new_message_from', 'New message from {name}!').replace('{name}', conversation.worker_name), { toast.info(t('new_message_from', 'New message from {name}!').replace('{name}', conversation.worker_name), {
description: autoReply.text, description: t('auto_reply_text', "Thank you for your response! I have reviewed your offer proposal and am looking forward to our video interview."),
duration: 5000 duration: 5000
}); });
}
}
});
}, 2000); }, 2000);
}, 1500); }, 1500);
} }
@ -136,6 +143,7 @@ export default function Show({ conversation, initialMessages, conversations = []
}; };
const chips = [ const chips = [
t('chip_looking_job', "Are you looking for a job?"),
t('chip_interview', "Are you available for a video interview today?"), t('chip_interview', "Are you available for a video interview today?"),
t('chip_salary', "What is your final expected salary?"), t('chip_salary', "What is your final expected salary?"),
t('chip_visa', "Can you transfer your visa immediately?") t('chip_visa', "Can you transfer your visa immediately?")

View File

@ -9,6 +9,7 @@
use App\Http\Controllers\Api\EmployerMessageController; use App\Http\Controllers\Api\EmployerMessageController;
use App\Http\Controllers\Api\EmployerAnnouncementController; use App\Http\Controllers\Api\EmployerAnnouncementController;
use App\Http\Controllers\Api\EmployerProfileController; use App\Http\Controllers\Api\EmployerProfileController;
use App\Http\Controllers\Api\EmployerReviewController;
/* /*
|-------------------------------------------------------------------------- |--------------------------------------------------------------------------
@ -51,6 +52,8 @@
Route::post('/workers/go-live', [WorkerProfileController::class, 'goLive']); Route::post('/workers/go-live', [WorkerProfileController::class, 'goLive']);
Route::post('/workers/profile/toggle-availability', [WorkerProfileController::class, 'toggleAvailability']); Route::post('/workers/profile/toggle-availability', [WorkerProfileController::class, 'toggleAvailability']);
Route::post('/workers/profile/mark-hired', [WorkerProfileController::class, 'markHired']); Route::post('/workers/profile/mark-hired', [WorkerProfileController::class, 'markHired']);
Route::get('/workers/dashboard/views', [WorkerProfileController::class, 'getProfileViews']);
Route::get('/workers/dashboard', [WorkerProfileController::class, 'getDashboard']);
// Job Offers Management // Job Offers Management
Route::get('/workers/offers', [WorkerProfileController::class, 'getOffers']); Route::get('/workers/offers', [WorkerProfileController::class, 'getOffers']);
@ -84,10 +87,15 @@
// Worker and Candidate Pipeline Management // Worker and Candidate Pipeline Management
Route::get('/employers/workers', [\App\Http\Controllers\Api\EmployerWorkerController::class, 'getWorkers']); Route::get('/employers/workers', [\App\Http\Controllers\Api\EmployerWorkerController::class, 'getWorkers']);
Route::get('/employers/workers/{id}', [\App\Http\Controllers\Api\EmployerWorkerController::class, 'getWorkerDetail']);
Route::get('/employers/candidates', [\App\Http\Controllers\Api\EmployerWorkerController::class, 'getCandidates']); Route::get('/employers/candidates', [\App\Http\Controllers\Api\EmployerWorkerController::class, 'getCandidates']);
Route::post('/employers/candidates/{id}/hire', [\App\Http\Controllers\Api\EmployerWorkerController::class, 'hireCandidate']); Route::post('/employers/candidates/{id}/hire', [\App\Http\Controllers\Api\EmployerWorkerController::class, 'hireCandidate']);
Route::post('/employers/candidates/hire', [\App\Http\Controllers\Api\EmployerWorkerController::class, 'hireCandidate']); Route::post('/employers/candidates/hire', [\App\Http\Controllers\Api\EmployerWorkerController::class, 'hireCandidate']);
// Payment History Management // Payment History Management
Route::get('/employers/payments', [\App\Http\Controllers\Api\EmployerPaymentController::class, 'getPayments']); Route::get('/employers/payments', [\App\Http\Controllers\Api\EmployerPaymentController::class, 'getPayments']);
// Review Management
Route::post('/employers/reviews', [EmployerReviewController::class, 'addReview']);
Route::put('/employers/reviews/{id}', [EmployerReviewController::class, 'editReview']);
}); });

View File

@ -19,7 +19,7 @@ export default defineConfig({
tailwindcss(), tailwindcss(),
], ],
server: { server: {
host: 'localhost', host: '192.168.29.131',
watch: { watch: {
ignored: ['**/storage/framework/views/**'], ignored: ['**/storage/framework/views/**'],
}, },