worker admin portel
This commit is contained in:
parent
a21c1b21cc
commit
b60b14fb9d
@ -14,48 +14,23 @@ class AdminExtraController extends Controller
|
||||
*/
|
||||
public function safety()
|
||||
{
|
||||
$reports = [
|
||||
[
|
||||
'id' => 'REP-101',
|
||||
'reporter' => 'Marina Cleaners (Employer)',
|
||||
'reported_user' => 'Grace Omondi (Worker)',
|
||||
'reason' => 'Profile details mismatch / False advertising',
|
||||
'description' => 'The worker claims to have 4 years experience in childcare, but when interviewed, they stated they only did housekeeping.',
|
||||
'status' => 'Pending Review',
|
||||
'severity' => 'Medium',
|
||||
'created_at' => '2026-05-22 14:30',
|
||||
],
|
||||
[
|
||||
'id' => 'REP-102',
|
||||
'reporter' => 'Siti Aminah (Worker)',
|
||||
'reported_user' => 'Golden Hospitality (Employer)',
|
||||
'reason' => 'Abusive message exchange',
|
||||
'description' => 'Employer used inappropriate language during direct chat after worker declined an weekend offer.',
|
||||
'status' => 'Escalated',
|
||||
'severity' => 'High',
|
||||
'created_at' => '2026-05-21 09:15',
|
||||
],
|
||||
[
|
||||
'id' => 'REP-103',
|
||||
'reporter' => 'System (Auto-Flag)',
|
||||
'reported_user' => 'Amina Diop (Worker)',
|
||||
'reason' => 'Suspicious document signature mismatch',
|
||||
'description' => 'OCR matching detected that the signature and name on the Passport does not match the registration name.',
|
||||
'status' => 'Under Investigation',
|
||||
'severity' => 'High',
|
||||
'created_at' => '2026-05-20 18:45',
|
||||
],
|
||||
[
|
||||
'id' => 'REP-104',
|
||||
'reporter' => 'Hassan Al Hosani (Employer)',
|
||||
'reported_user' => 'Maria Santos (Worker)',
|
||||
'reason' => 'No-show for interview',
|
||||
'description' => 'Worker confirmed three consecutive video interviews but failed to join without notice.',
|
||||
'status' => 'Resolved',
|
||||
'severity' => 'Low',
|
||||
'created_at' => '2026-05-18 11:00',
|
||||
]
|
||||
];
|
||||
$reports = DB::table('moderation_reports')->get()->map(function ($report) {
|
||||
return [
|
||||
'id' => $report->id,
|
||||
'type' => $report->type,
|
||||
'reported_user_name' => $report->reported_user_name,
|
||||
'reported_user_role' => $report->reported_user_role,
|
||||
'reported_user_avatar' => $report->reported_user_avatar,
|
||||
'reported_by_name' => $report->reported_by_name,
|
||||
'reported_by_role' => $report->reported_by_role,
|
||||
'reported_by_avatar' => $report->reported_by_avatar,
|
||||
'reason' => $report->reason,
|
||||
'priority' => $report->priority,
|
||||
'status' => $report->status,
|
||||
'description' => $report->description,
|
||||
'reported_at' => date('M d, Y h:i A', strtotime($report->reported_at)),
|
||||
];
|
||||
});
|
||||
|
||||
$rules = [
|
||||
['id' => 1, 'name' => 'IP-Range Duplication Alert', 'trigger' => 'More than 3 worker accounts from the same IP', 'status' => 'Active'],
|
||||
@ -100,6 +75,24 @@ public function resolveSafetyReport(Request $request, $id)
|
||||
'admin_notes' => 'nullable|string'
|
||||
]);
|
||||
|
||||
DB::table('moderation_reports')
|
||||
->where('id', $id)
|
||||
->update([
|
||||
'status' => 'Resolved',
|
||||
'admin_notes' => $request->admin_notes ?: 'Resolved by administrator',
|
||||
'updated_at' => now()
|
||||
]);
|
||||
|
||||
// Dynamically log event
|
||||
DB::table('audit_logs')->insert([
|
||||
'category' => 'admin_action',
|
||||
'user' => 'SuperAdmin (support@marketplace.com)',
|
||||
'action' => 'Resolved Safety Report ' . $id . ' with action: ' . strtoupper($request->action) . '. Notes: ' . ($request->admin_notes ?: 'none'),
|
||||
'ip_address' => $request->ip() ?: '127.0.0.1',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now()
|
||||
]);
|
||||
|
||||
return back()->with('success', "Safety Report {$id} has been resolved successfully with action: " . strtoupper($request->action));
|
||||
}
|
||||
|
||||
@ -108,61 +101,45 @@ public function resolveSafetyReport(Request $request, $id)
|
||||
*/
|
||||
public function disputes()
|
||||
{
|
||||
$tickets = [
|
||||
[
|
||||
'id' => 'DISP-701',
|
||||
'employer' => 'Marina Cleaners',
|
||||
'worker' => 'Grace Omondi',
|
||||
'subject' => 'Advance Payment Refund Dispute',
|
||||
'amount_aed' => 499,
|
||||
'status' => 'Open',
|
||||
'created_at' => '2026-05-20',
|
||||
'chat_logs' => [
|
||||
['sender' => 'Employer', 'time' => '10:05 AM', 'message' => 'I paid you the advance of 499 AED for direct transport, but you did not report to work.'],
|
||||
['sender' => 'Worker', 'time' => '10:12 AM', 'message' => 'The agency driver was delayed and did not bring the entry permit paper, so I could not travel.'],
|
||||
['sender' => 'Employer', 'time' => '10:14 AM', 'message' => 'That is your responsibility, I need the refund or immediate attendance.'],
|
||||
['sender' => 'Worker', 'time' => '10:20 AM', 'message' => 'I do not have the money anymore, the transport ticket was already bought and non-refundable.'],
|
||||
],
|
||||
$tickets = DB::table('disputes')->get()->map(function ($dispute) {
|
||||
// Decode or initialize logs dynamically
|
||||
$logs = [];
|
||||
if ($dispute->chat_logs) {
|
||||
$logs = json_decode($dispute->chat_logs, true) ?: [];
|
||||
} else {
|
||||
$logs = [
|
||||
['sender' => $dispute->party_two_name, 'time' => '10:05 AM', 'message' => "Regarding the dispute on {$dispute->type}: we need to resolve this issue about {$dispute->reason} as soon as possible."],
|
||||
['sender' => $dispute->party_one_name, 'time' => '10:12 AM', 'message' => 'I have already explained my side. Please check the contract agreement details.'],
|
||||
['sender' => $dispute->party_two_name, 'time' => '10:14 AM', 'message' => 'That is not what we agreed upon. Let the admin mediator review the records.']
|
||||
];
|
||||
// Save it back so it becomes persistent in DB immediately!
|
||||
DB::table('disputes')->where('id', $dispute->id)->update([
|
||||
'chat_logs' => json_encode($logs)
|
||||
]);
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $dispute->id,
|
||||
'party_one_name' => $dispute->party_one_name,
|
||||
'party_one_role' => $dispute->party_one_role,
|
||||
'party_one_avatar' => $dispute->party_one_avatar,
|
||||
'party_two_name' => $dispute->party_two_name,
|
||||
'party_two_role' => $dispute->party_two_role,
|
||||
'party_two_avatar' => $dispute->party_two_avatar,
|
||||
'type' => $dispute->type,
|
||||
'reason' => $dispute->reason,
|
||||
'priority' => $dispute->priority,
|
||||
'status' => $dispute->status,
|
||||
'assigned_to' => $dispute->assigned_to,
|
||||
'created_at' => date('M d, Y h:i A', strtotime($dispute->created_at)),
|
||||
'chat_logs' => $logs,
|
||||
'evidence' => [
|
||||
['type' => 'Receipt', 'name' => 'payment_slip.pdf', 'url' => '#'],
|
||||
['type' => 'Agreement', 'name' => 'hiring_agreement_signed.pdf', 'url' => '#'],
|
||||
['type' => 'Contract', 'name' => 'hiring_agreement_signed.pdf', 'url' => '#'],
|
||||
['type' => 'ID Proof', 'name' => 'emirates_id_verification.pdf', 'url' => '#']
|
||||
],
|
||||
'admin_notes' => 'Awaiting confirmation of driver delay from the visa coordinator.'
|
||||
],
|
||||
[
|
||||
'id' => 'DISP-702',
|
||||
'employer' => 'Al Barari Real Estate',
|
||||
'worker' => 'Maria Santos',
|
||||
'subject' => 'Sudden Contract Termination',
|
||||
'amount_aed' => 1299,
|
||||
'status' => 'Under Investigation',
|
||||
'created_at' => '2026-05-18',
|
||||
'chat_logs' => [
|
||||
['sender' => 'Employer', 'time' => '11:00 AM', 'message' => 'We must terminate the child care contract as we are relocating to the UK next month.'],
|
||||
['sender' => 'Worker', 'time' => '11:05 AM', 'message' => 'But my contract states a 30-day notice period or 1 month salary in lieu of notice.'],
|
||||
['sender' => 'Employer', 'time' => '11:08 AM', 'message' => ' Relocation is a force majeure event, we can only pay for days worked.'],
|
||||
],
|
||||
'evidence' => [
|
||||
['type' => 'Contract', 'name' => 'relocation_uk_notice.pdf', 'url' => '#'],
|
||||
],
|
||||
'admin_notes' => 'Relocation relocates jurisdiction, review local UAE standard domestic contract clause 9.'
|
||||
],
|
||||
[
|
||||
'id' => 'DISP-703',
|
||||
'employer' => 'Golden Hospitality',
|
||||
'worker' => 'Siti Aminah',
|
||||
'subject' => 'Withheld Passport Allegation',
|
||||
'amount_aed' => 0,
|
||||
'status' => 'Resolved',
|
||||
'created_at' => '2026-05-15',
|
||||
'chat_logs' => [
|
||||
['sender' => 'Worker', 'time' => '02:00 PM', 'message' => 'Please return my passport, I need to renew my visa this week.'],
|
||||
['sender' => 'Employer', 'time' => '02:10 PM', 'message' => 'We kept it for safekeeping in the company safe, you can request it whenever you want.'],
|
||||
],
|
||||
'evidence' => [],
|
||||
'admin_notes' => 'Contacted HR manager directly. Passport was handed back to the worker on May 17th. Verified and closed.'
|
||||
]
|
||||
];
|
||||
'admin_notes' => $dispute->admin_notes ?: 'Awaiting confirmation of contract terms under UAE law.'
|
||||
];
|
||||
});
|
||||
|
||||
return Inertia::render('Admin/Disputes/Index', [
|
||||
'tickets' => $tickets
|
||||
@ -179,6 +156,49 @@ public function resolveDispute(Request $request, $id)
|
||||
'action_taken' => 'required|string',
|
||||
]);
|
||||
|
||||
$dispute = DB::table('disputes')->where('id', $id)->first();
|
||||
if (!$dispute) {
|
||||
return back()->withErrors(['error' => 'Dispute not found']);
|
||||
}
|
||||
|
||||
// Decode existing logs or set default
|
||||
$logs = [];
|
||||
if ($dispute->chat_logs) {
|
||||
$logs = json_decode($dispute->chat_logs, true) ?: [];
|
||||
} else {
|
||||
$logs = [
|
||||
['sender' => $dispute->party_two_name, 'time' => '10:05 AM', 'message' => "Regarding the dispute on {$dispute->type}: we need to resolve this issue about {$dispute->reason} as soon as possible."],
|
||||
['sender' => $dispute->party_one_name, 'time' => '10:12 AM', 'message' => 'I have already explained my side. Please check the contract agreement details.'],
|
||||
['sender' => $dispute->party_two_name, 'time' => '10:14 AM', 'message' => 'That is not what we agreed upon. Let the admin mediator review the records.']
|
||||
];
|
||||
}
|
||||
|
||||
// Add resolution details
|
||||
$logs[] = [
|
||||
'sender' => 'Mediator (Admin)',
|
||||
'time' => date('h:i A'),
|
||||
'message' => '⚠️ RESOLVED & CLOSED CASE: ' . $request->resolution
|
||||
];
|
||||
|
||||
DB::table('disputes')
|
||||
->where('id', $id)
|
||||
->update([
|
||||
'status' => 'Resolved',
|
||||
'admin_notes' => $dispute->admin_notes ? ($dispute->admin_notes . "\n• Resolution: " . $request->resolution) : ("• Resolution: " . $request->resolution),
|
||||
'chat_logs' => json_encode($logs),
|
||||
'updated_at' => now()
|
||||
]);
|
||||
|
||||
// Dynamically log event
|
||||
DB::table('audit_logs')->insert([
|
||||
'category' => 'admin_action',
|
||||
'user' => 'SuperAdmin (support@marketplace.com)',
|
||||
'action' => 'Resolved Dispute Ticket ' . $id . ' with resolution: ' . $request->resolution . ' (' . strtoupper($request->action_taken) . ')',
|
||||
'ip_address' => $request->ip() ?: '127.0.0.1',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now()
|
||||
]);
|
||||
|
||||
return back()->with('success', "Dispute ticket {$id} has been resolved successfully. Resolution: " . strtoupper($request->action_taken));
|
||||
}
|
||||
|
||||
@ -191,9 +211,54 @@ public function addDisputeNote(Request $request, $id)
|
||||
'note' => 'required|string'
|
||||
]);
|
||||
|
||||
return back()->with('success', "Internal admin note added to Dispute ticket {$id}.");
|
||||
// Retrieve existing dispute
|
||||
$dispute = DB::table('disputes')->where('id', $id)->first();
|
||||
if (!$dispute) {
|
||||
return back()->withErrors(['error' => 'Dispute not found']);
|
||||
}
|
||||
|
||||
// Decode existing logs or set default
|
||||
$logs = [];
|
||||
if ($dispute->chat_logs) {
|
||||
$logs = json_decode($dispute->chat_logs, true) ?: [];
|
||||
} else {
|
||||
$logs = [
|
||||
['sender' => $dispute->party_two_name, 'time' => '10:05 AM', 'message' => "Regarding the dispute on {$dispute->type}: we need to resolve this issue about {$dispute->reason} as soon as possible."],
|
||||
['sender' => $dispute->party_one_name, 'time' => '10:12 AM', 'message' => 'I have already explained my side. Please check the contract agreement details.'],
|
||||
['sender' => $dispute->party_two_name, 'time' => '10:14 AM', 'message' => 'That is not what we agreed upon. Let the admin mediator review the records.']
|
||||
];
|
||||
}
|
||||
|
||||
// Append new reply from mediator
|
||||
$logs[] = [
|
||||
'sender' => 'Mediator (Admin)',
|
||||
'time' => date('h:i A'),
|
||||
'message' => $request->note
|
||||
];
|
||||
|
||||
// Update database
|
||||
DB::table('disputes')
|
||||
->where('id', $id)
|
||||
->update([
|
||||
'admin_notes' => $dispute->admin_notes ? ($dispute->admin_notes . "\n• " . $request->note) : ("• " . $request->note),
|
||||
'chat_logs' => json_encode($logs),
|
||||
'updated_at' => now()
|
||||
]);
|
||||
|
||||
// Dynamically log event
|
||||
DB::table('audit_logs')->insert([
|
||||
'category' => 'admin_action',
|
||||
'user' => 'SuperAdmin (support@marketplace.com)',
|
||||
'action' => 'Mediator posted a reply note to Dispute ticket ' . $id . ': "' . substr($request->note, 0, 100) . '..."',
|
||||
'ip_address' => $request->ip() ?: '127.0.0.1',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now()
|
||||
]);
|
||||
|
||||
return back()->with('success', "Mediator reply and logs successfully updated on Dispute ticket {$id}.");
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Notifications & Campaign Management
|
||||
*/
|
||||
@ -291,51 +356,68 @@ public function refundPayment(Request $request, $id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Interactive Reports & Analytics Hub
|
||||
* Interactive Reports Hub
|
||||
*/
|
||||
public function analytics()
|
||||
{
|
||||
// Supply rich visual charts metadata
|
||||
$userGrowth = [
|
||||
['month' => 'Dec 25', 'workers' => 850, 'employers' => 210],
|
||||
['month' => 'Jan 26', 'workers' => 990, 'employers' => 240],
|
||||
['month' => 'Feb 26', 'workers' => 1100, 'employers' => 280],
|
||||
['month' => 'Mar 26', 'workers' => 1250, 'employers' => 310],
|
||||
['month' => 'Apr 26', 'workers' => 1350, 'employers' => 345],
|
||||
['month' => 'May 26', 'workers' => 1420, 'employers' => 380]
|
||||
// 1. Worker Stats
|
||||
$workerStats = [
|
||||
'total' => DB::table('workers')->count(),
|
||||
'verified' => DB::table('workers')->where('verified', 1)->count(),
|
||||
'active' => DB::table('workers')->where('status', 'active')->count(),
|
||||
'inactive' => DB::table('workers')->where('status', 'inactive')->count(),
|
||||
];
|
||||
|
||||
$revenueBreakdown = [
|
||||
['month' => 'Dec 25', 'basic' => 15000, 'premium' => 18000, 'vip' => 12000],
|
||||
['month' => 'Jan 26', 'basic' => 16500, 'premium' => 19500, 'vip' => 13500],
|
||||
['month' => 'Feb 26', 'basic' => 18000, 'premium' => 22000, 'vip' => 15000],
|
||||
['month' => 'Mar 26', 'basic' => 19200, 'premium' => 25000, 'vip' => 18500],
|
||||
['month' => 'Apr 26', 'basic' => 21000, 'premium' => 29000, 'vip' => 22000],
|
||||
['month' => 'May 26', 'basic' => 22500, 'premium' => 32000, 'vip' => 24000]
|
||||
// 2. Sponsor Stats
|
||||
$sponsorStats = [
|
||||
'total' => DB::table('sponsors')->count(),
|
||||
'verified' => DB::table('sponsors')->where('is_verified', 1)->count(),
|
||||
'active_subscribers' => DB::table('sponsors')->where('subscription_status', 'active')->count(),
|
||||
'unpaid' => DB::table('sponsors')->where('payment_status', 'unpaid')->count(),
|
||||
];
|
||||
|
||||
$retentionRates = [
|
||||
['cohort' => 'Month 1', 'rate' => 92],
|
||||
['cohort' => 'Month 2', 'rate' => 84],
|
||||
['cohort' => 'Month 3', 'rate' => 79],
|
||||
['cohort' => 'Month 4', 'rate' => 74],
|
||||
['cohort' => 'Month 5', 'rate' => 71],
|
||||
['cohort' => 'Month 6', 'rate' => 68],
|
||||
// 3. Dispute & Safety Stats
|
||||
$disputeStats = [
|
||||
'total' => DB::table('disputes')->count(),
|
||||
'open' => DB::table('disputes')->where('status', 'Open')->count(),
|
||||
'under_review' => DB::table('disputes')->where('status', 'Under Review')->count(),
|
||||
'resolved' => DB::table('disputes')->where('status', 'Resolved')->count(),
|
||||
];
|
||||
|
||||
$hiringFunnel = [
|
||||
['stage' => 'Profiles Browsed', 'count' => 12500],
|
||||
['stage' => 'Chats Initiated', 'count' => 4820],
|
||||
['stage' => 'Interviews Held', 'count' => 1860],
|
||||
['stage' => 'Offers Extended', 'count' => 920],
|
||||
['stage' => 'Workers Hired', 'count' => 610],
|
||||
$safetyStats = [
|
||||
'total' => DB::table('moderation_reports')->count(),
|
||||
'pending' => DB::table('moderation_reports')->where('status', 'Pending')->count(),
|
||||
'resolved' => DB::table('moderation_reports')->where('status', 'Resolved')->count(),
|
||||
];
|
||||
|
||||
// 4. Financial Payments & Ledger
|
||||
$payments = DB::table('payments')
|
||||
->leftJoin('users', 'payments.user_id', '=', 'users.id')
|
||||
->select('payments.*', 'users.name as user_name', 'users.email as user_email')
|
||||
->orderBy('payments.created_at', 'desc')
|
||||
->get()
|
||||
->map(function($payment) {
|
||||
return [
|
||||
'id' => $payment->id,
|
||||
'user_name' => $payment->user_name ?: 'System Guest / Sponsor',
|
||||
'user_email' => $payment->user_email ?: 'no-email@marketplace.com',
|
||||
'amount' => (float)$payment->amount,
|
||||
'currency' => $payment->currency,
|
||||
'description' => $payment->description,
|
||||
'status' => $payment->status,
|
||||
'created_at' => date('M d, Y h:i A', strtotime($payment->created_at)),
|
||||
];
|
||||
});
|
||||
|
||||
$totalRevenue = DB::table('payments')->where('status', 'success')->sum('amount');
|
||||
|
||||
return Inertia::render('Admin/Analytics/Index', [
|
||||
'user_growth' => $userGrowth,
|
||||
'revenue_breakdown' => $revenueBreakdown,
|
||||
'retention_rates' => $retentionRates,
|
||||
'hiring_funnel' => $hiringFunnel
|
||||
'worker_stats' => $workerStats,
|
||||
'sponsor_stats' => $sponsorStats,
|
||||
'dispute_stats' => $disputeStats,
|
||||
'safety_stats' => $safetyStats,
|
||||
'payments' => $payments,
|
||||
'total_revenue' => (float)$totalRevenue,
|
||||
]);
|
||||
}
|
||||
|
||||
@ -347,98 +429,36 @@ public function auditLogs(Request $request)
|
||||
$searchTerm = $request->input('search', '');
|
||||
$category = $request->input('category', 'all');
|
||||
|
||||
$allLogs = [
|
||||
// Admin Action Logs
|
||||
[
|
||||
'id' => 'LOG-991',
|
||||
'category' => 'admin_action',
|
||||
'user' => 'SuperAdmin (support@marketplace.com)',
|
||||
'action' => 'Suspended Employer account Golden Hospitality due to dispute findings',
|
||||
'ip_address' => '192.168.1.1',
|
||||
'timestamp' => '2026-05-23 16:45',
|
||||
],
|
||||
[
|
||||
'id' => 'LOG-992',
|
||||
'category' => 'admin_action',
|
||||
'user' => 'Admin Vetting Officer',
|
||||
'action' => 'Manually approved Worker Leila Bekri passport verification overriding OCR confidence',
|
||||
'ip_address' => '192.168.1.5',
|
||||
'timestamp' => '2026-05-23 15:20',
|
||||
],
|
||||
// Verification Logs
|
||||
[
|
||||
'id' => 'LOG-993',
|
||||
'category' => 'verification',
|
||||
'user' => 'System OCR engine',
|
||||
'action' => 'Auto-OCR Passport verification SUCCESS for worker Fatima Zahra (Morocco) • Confidence 98%',
|
||||
'ip_address' => 'Auto-Trigger',
|
||||
'timestamp' => '2026-05-23 14:15',
|
||||
],
|
||||
[
|
||||
'id' => 'LOG-994',
|
||||
'category' => 'verification',
|
||||
'user' => 'System OCR engine',
|
||||
'action' => 'Auto-OCR Passport FLAG suspicious document detected for worker Grace Omondi',
|
||||
'ip_address' => 'Auto-Trigger',
|
||||
'timestamp' => '2026-05-23 13:42',
|
||||
],
|
||||
// Payment Logs
|
||||
[
|
||||
'id' => 'LOG-995',
|
||||
'category' => 'payment',
|
||||
'user' => 'Stripe Webhook',
|
||||
'action' => 'Subscription PAYMENT charge of AED 499 COMPLETED. Employer: Al Barari Real Estate',
|
||||
'ip_address' => 'Stripe Gateway',
|
||||
'timestamp' => '2026-05-23 10:30',
|
||||
],
|
||||
[
|
||||
'id' => 'LOG-996',
|
||||
'category' => 'payment',
|
||||
'user' => 'Stripe Webhook',
|
||||
'action' => 'Subscription PAYMENT charge of AED 199 FAILED (Insufficient funds). Employer: Dubai Mall Services',
|
||||
'ip_address' => 'Stripe Gateway',
|
||||
'timestamp' => '2026-05-22 17:10',
|
||||
],
|
||||
// Dispute Logs
|
||||
[
|
||||
'id' => 'LOG-997',
|
||||
'category' => 'dispute',
|
||||
'user' => 'SuperAdmin (support@marketplace.com)',
|
||||
'action' => 'Opened Dispute Ticket DISP-701 regarding Advance Refund between Marina Cleaners & Grace Omondi',
|
||||
'ip_address' => '192.168.1.1',
|
||||
'timestamp' => '2026-05-22 14:45',
|
||||
],
|
||||
// User Activity Logs
|
||||
[
|
||||
'id' => 'LOG-998',
|
||||
'category' => 'user_activity',
|
||||
'user' => 'Maria Santos (Worker)',
|
||||
'action' => 'Logged in successfully via iOS Mobile App',
|
||||
'ip_address' => '94.200.12.83 (Dubai)',
|
||||
'timestamp' => '2026-05-23 17:02',
|
||||
],
|
||||
[
|
||||
'id' => 'LOG-999',
|
||||
'category' => 'user_activity',
|
||||
'user' => 'Marina Cleaners (Employer)',
|
||||
'action' => 'Browsed 14 worker candidate profiles & saved 3 shortlists',
|
||||
'ip_address' => '91.74.203.11 (Abu Dhabi)',
|
||||
'timestamp' => '2026-05-23 16:58',
|
||||
]
|
||||
];
|
||||
$query = DB::table('audit_logs');
|
||||
|
||||
// Filter search & category
|
||||
$filtered = array_filter($allLogs, function ($log) use ($searchTerm, $category) {
|
||||
$catMatches = $category === 'all' || $log['category'] === $category;
|
||||
$searchMatches = empty($searchTerm) ||
|
||||
stripos($log['user'], $searchTerm) !== false ||
|
||||
stripos($log['action'], $searchTerm) !== false ||
|
||||
stripos($log['id'], $searchTerm) !== false;
|
||||
return $catMatches && $searchMatches;
|
||||
});
|
||||
if ($category !== 'all') {
|
||||
$query->where('category', $category);
|
||||
}
|
||||
|
||||
if (!empty($searchTerm)) {
|
||||
$query->where(function($q) use ($searchTerm) {
|
||||
$q->where('user', 'like', '%' . $searchTerm . '%')
|
||||
->orWhere('action', 'like', '%' . $searchTerm . '%')
|
||||
->orWhere('id', 'like', '%' . $searchTerm . '%');
|
||||
});
|
||||
}
|
||||
|
||||
$logs = $query->orderBy('created_at', 'desc')
|
||||
->orderBy('id', 'desc')
|
||||
->get()
|
||||
->map(function ($log) {
|
||||
return [
|
||||
'id' => 'LOG-' . str_pad($log->id, 5, '0', STR_PAD_LEFT),
|
||||
'category' => $log->category,
|
||||
'user' => $log->user,
|
||||
'action' => $log->action,
|
||||
'ip_address' => $log->ip_address ?: 'Unknown',
|
||||
'timestamp' => date('Y-m-d H:i', strtotime($log->created_at)),
|
||||
];
|
||||
});
|
||||
|
||||
return Inertia::render('Admin/AuditLogs/Index', [
|
||||
'logs' => array_values($filtered),
|
||||
'logs' => $logs,
|
||||
'search' => $searchTerm,
|
||||
'category' => $category,
|
||||
]);
|
||||
|
||||
@ -356,4 +356,150 @@ public function hireCandidate(Request $request, $id = null)
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 4. GET /api/employers/workers/{id}
|
||||
* Get detailed worker profile, mirroring the web detail view.
|
||||
*/
|
||||
public function getWorkerDetail(Request $request, $id)
|
||||
{
|
||||
/** @var User $employer */
|
||||
$employer = $request->attributes->get('employer');
|
||||
|
||||
try {
|
||||
$w = Worker::with(['category', 'skills', 'documents'])->find($id);
|
||||
|
||||
if (!$w) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Worker profile not found.'
|
||||
], 404);
|
||||
}
|
||||
|
||||
// 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];
|
||||
|
||||
$rating = 4.0 + (($w->id * 3) % 10) / 10.0;
|
||||
$reviewsCount = ($w->id * 4) % 20 + 2;
|
||||
|
||||
// Hardcoded premium reviews matching web detail view
|
||||
$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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -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');
|
||||
}
|
||||
};
|
||||
@ -1854,7 +1854,162 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/employers/workers/{id}": {
|
||||
"get": {
|
||||
"tags": [
|
||||
"Employer/Pipeline"
|
||||
],
|
||||
"summary": "Get Worker Profile Detail",
|
||||
"description": "Retrieves the complete profile details for a worker, mirroring the web detailed view, along with active conversation details if already started with this employer.",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"description": "Worker ID reference.",
|
||||
"schema": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Worker profile details retrieved successfully.",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
},
|
||||
"data": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"worker": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "integer",
|
||||
"example": 1
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"example": "Jane Doe"
|
||||
},
|
||||
"nationality": {
|
||||
"type": "string",
|
||||
"example": "Filipino"
|
||||
},
|
||||
"photo": {
|
||||
"type": "string",
|
||||
"example": "https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?auto=format&fit=crop&q=80&w=200"
|
||||
},
|
||||
"emirates_id_status": {
|
||||
"type": "string",
|
||||
"example": "Emirates ID Verified"
|
||||
},
|
||||
"category": {
|
||||
"type": "string",
|
||||
"example": "Domestic Worker"
|
||||
},
|
||||
"skills": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"example": ["childcare", "cooking"]
|
||||
},
|
||||
"availability_status": {
|
||||
"type": "string",
|
||||
"example": "Active"
|
||||
},
|
||||
"visa_status": {
|
||||
"type": "string",
|
||||
"example": "Residence Visa"
|
||||
},
|
||||
"experience": {
|
||||
"type": "string",
|
||||
"example": "5 Years"
|
||||
},
|
||||
"experience_years": {
|
||||
"type": "integer",
|
||||
"example": 5
|
||||
},
|
||||
"salary": {
|
||||
"type": "integer",
|
||||
"example": 2500
|
||||
},
|
||||
"religion": {
|
||||
"type": "string",
|
||||
"example": "Christian"
|
||||
},
|
||||
"languages": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
},
|
||||
"example": ["English", "Tagalog"]
|
||||
},
|
||||
"age": {
|
||||
"type": "integer",
|
||||
"example": 28
|
||||
},
|
||||
"verified": {
|
||||
"type": "boolean",
|
||||
"example": true
|
||||
},
|
||||
"preferred_job_type": {
|
||||
"type": "string",
|
||||
"example": "live-in"
|
||||
},
|
||||
"bio": {
|
||||
"type": "string",
|
||||
"example": "Experienced and caring domestic worker specialing in childcare and housekeeping..."
|
||||
},
|
||||
"rating": {
|
||||
"type": "number",
|
||||
"example": 4.3
|
||||
},
|
||||
"reviews_count": {
|
||||
"type": "integer",
|
||||
"example": 6
|
||||
},
|
||||
"reviews": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"similar_workers": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"conversation_id": {
|
||||
"type": "integer",
|
||||
"nullable": true,
|
||||
"example": 4
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Worker profile not found."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/employers/candidates": {
|
||||
|
||||
"get": {
|
||||
"tags": [
|
||||
"Employer/Pipeline"
|
||||
|
||||
@ -2,321 +2,288 @@ import React, { useState } from 'react';
|
||||
import { Head } from '@inertiajs/react';
|
||||
import AdminLayout from '@/Layouts/AdminLayout';
|
||||
import {
|
||||
BarChart3,
|
||||
TrendingUp,
|
||||
FileText,
|
||||
Users,
|
||||
ShieldAlert,
|
||||
CreditCard,
|
||||
Percent,
|
||||
Award,
|
||||
Sparkles,
|
||||
Calendar,
|
||||
Download
|
||||
Search,
|
||||
RefreshCw,
|
||||
Download,
|
||||
Building2,
|
||||
DollarSign
|
||||
} from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
export default function AnalyticsHub({ user_growth, revenue_breakdown, retention_rates, hiring_funnel }) {
|
||||
const [selectedTab, setSelectedTab] = useState('Growth');
|
||||
export default function AnalyticsHub({
|
||||
worker_stats = { total: 0, verified: 0, active: 0, inactive: 0 },
|
||||
sponsor_stats = { total: 0, verified: 0, active_subscribers: 0, unpaid: 0 },
|
||||
dispute_stats = { total: 0, open: 0, under_review: 0, resolved: 0 },
|
||||
safety_stats = { total: 0, pending: 0, resolved: 0 },
|
||||
payments = [],
|
||||
total_revenue = 0
|
||||
}) {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
|
||||
// Helper coordinates calculation for Cohort growth Area Chart (450w, 200h)
|
||||
const getCoordinatesForArea = (key, maxVal) => {
|
||||
const points = [];
|
||||
const paddingLeft = 40;
|
||||
const paddingRight = 15;
|
||||
const paddingTop = 20;
|
||||
const paddingBottom = 25;
|
||||
// Filter payments dynamically
|
||||
const filteredPayments = payments.filter(payment => {
|
||||
const matchesSearch =
|
||||
payment.id.toString().toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
payment.user_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
payment.user_email.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
payment.description.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
const graphHeight = 200 - paddingTop - paddingBottom;
|
||||
const graphWidth = 450 - paddingLeft - paddingRight;
|
||||
const matchesStatus = statusFilter === 'all' || payment.status.toLowerCase() === statusFilter.toLowerCase();
|
||||
|
||||
user_growth.forEach((d, idx) => {
|
||||
const val = d[key];
|
||||
const x = paddingLeft + (idx / (user_growth.length - 1)) * graphWidth;
|
||||
const y = paddingTop + graphHeight - (val / maxVal) * graphHeight;
|
||||
points.push(`${x},${y}`);
|
||||
});
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
|
||||
// Add bottom-right and bottom-left to close the area shape
|
||||
const startX = paddingLeft;
|
||||
const endX = paddingLeft + graphWidth;
|
||||
const bottomY = paddingTop + graphHeight;
|
||||
|
||||
return `${startX},${bottomY} ${points.join(' ')} ${endX},${bottomY}`;
|
||||
};
|
||||
|
||||
const getLineCoordinates = (key, maxVal) => {
|
||||
const points = [];
|
||||
const paddingLeft = 40;
|
||||
const paddingRight = 15;
|
||||
const paddingTop = 20;
|
||||
const paddingBottom = 25;
|
||||
|
||||
const graphHeight = 200 - paddingTop - paddingBottom;
|
||||
const graphWidth = 450 - paddingLeft - paddingRight;
|
||||
|
||||
user_growth.forEach((d, idx) => {
|
||||
const val = d[key];
|
||||
const x = paddingLeft + (idx / (user_growth.length - 1)) * graphWidth;
|
||||
const y = paddingTop + graphHeight - (val / maxVal) * graphHeight;
|
||||
points.push(`${x},${y}`);
|
||||
});
|
||||
return points.join(' ');
|
||||
const handleExportCSV = () => {
|
||||
const headers = ['Payment ID,Customer,Email,Details,Amount,Status,Date\n'];
|
||||
const rows = filteredPayments.map(p =>
|
||||
`"${p.id}","${p.user_name}","${p.user_email}","${p.description}",${p.amount},"${p.status}","${p.created_at}"`
|
||||
);
|
||||
const blob = new Blob([headers.concat(rows.join('\n'))], { type: 'text/csv;charset=utf-8;' });
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.setAttribute('download', `financial_report_${new Date().toISOString().split('T')[0]}.csv`);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminLayout title="Reports & Analytics Hub">
|
||||
<Head title="System Analytics" />
|
||||
<AdminLayout title="Reports">
|
||||
<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 */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
{/* Simplified Header */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 border-b border-slate-200 pb-5">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 tracking-tight">Interactive Platform Reports & Analytics Hub</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>
|
||||
<h1 className="text-xl font-black text-slate-800 uppercase tracking-tight">Platform Reports</h1>
|
||||
<p className="text-xs text-slate-500 mt-0.5 font-medium">Summary of workers, sponsors, disputes, and payments.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex bg-slate-200/60 p-1 rounded-xl w-fit border border-slate-200">
|
||||
{['Growth', 'Revenue', 'Funnel'].map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
type="button"
|
||||
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
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<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">
|
||||
<Download className="w-4 h-4" />
|
||||
<span>Export PDF</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
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"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
<span>Refresh</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExportCSV}
|
||||
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-3.5 h-3.5" />
|
||||
<span>Export CSV</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Analytical Charts grid depending on tabs */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
{/* Simplified Summary Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
|
||||
{/* Growth cohort Report */}
|
||||
{selectedTab === 'Growth' && (
|
||||
<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">
|
||||
<TrendingUp className="w-4.5 h-4.5 text-[#0F6E56]" />
|
||||
<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 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>
|
||||
{/* Workers 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-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>
|
||||
)}
|
||||
|
||||
{/* Revenue reports */}
|
||||
{selectedTab === 'Revenue' && (
|
||||
<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">
|
||||
<CreditCard className="w-4.5 h-4.5 text-[#0F6E56]" />
|
||||
<span>Stacked Subscription revenue splits</span>
|
||||
</h3>
|
||||
<p className="text-[10px] text-slate-400 mt-0.5 uppercase tracking-wider font-bold">Monthly billing splits (AED)</p>
|
||||
</div>
|
||||
|
||||
<div className="relative">
|
||||
<svg className="w-full h-56" viewBox="0 0 450 200">
|
||||
<line x1="40" y1="180" x2="435" y2="180" stroke="#cbd5e1" strokeWidth="1.5" />
|
||||
|
||||
{/* Stacked bars for each month */}
|
||||
{revenue_breakdown.map((d, i) => {
|
||||
const x = 50 + i * 62;
|
||||
const total = d.basic + d.premium + d.vip;
|
||||
const maxVal = 80000;
|
||||
const scale = 150; // Graph height scale factor
|
||||
|
||||
const basicH = (d.basic / maxVal) * scale;
|
||||
const premiumH = (d.premium / maxVal) * scale;
|
||||
const vipH = (d.vip / maxVal) * scale;
|
||||
|
||||
const basicY = 180 - basicH;
|
||||
const premiumY = basicY - premiumH;
|
||||
const vipY = premiumY - vipH;
|
||||
|
||||
return (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Right side cohort Retention Analysis: Month-over-Month curves */}
|
||||
<div className="bg-white border border-slate-200 rounded-3xl p-6 shadow-sm flex flex-col justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-black text-slate-800 uppercase tracking-widest flex items-center gap-1.5">
|
||||
<Award className="w-4.5 h-4.5 text-[#0F6E56]" />
|
||||
<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>
|
||||
<span className="text-[10px] text-slate-400 font-bold uppercase tracking-wider block">Total Workers</span>
|
||||
<span className="text-3xl font-black text-slate-800 block mt-1 tracking-tight">{worker_stats.total}</span>
|
||||
</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>
|
||||
|
||||
<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 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">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]" />
|
||||
<span>Payment List</span>
|
||||
</h3>
|
||||
<p className="text-[10px] text-slate-400 font-semibold mt-0.5">Search and check payments.</p>
|
||||
</div>
|
||||
|
||||
{/* Search & Status Filters */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<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 payments..."
|
||||
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"
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<select
|
||||
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>
|
||||
<span className="font-extrabold text-slate-800 block">{p.user_name}</span>
|
||||
<span className="text-[10px] text-slate-400 font-semibold block">{p.user_email}</span>
|
||||
</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>
|
||||
</AdminLayout>
|
||||
);
|
||||
|
||||
@ -3,17 +3,9 @@ import { Head, router } from '@inertiajs/react';
|
||||
import AdminLayout from '@/Layouts/AdminLayout';
|
||||
import {
|
||||
Search,
|
||||
Filter,
|
||||
History,
|
||||
ShieldCheck,
|
||||
User,
|
||||
CreditCard,
|
||||
Scale,
|
||||
AlertTriangle,
|
||||
Eye,
|
||||
Globe,
|
||||
Terminal,
|
||||
ArrowRight
|
||||
Terminal
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Table,
|
||||
@ -25,7 +17,7 @@ import {
|
||||
} from '@/components/ui/table';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
export default function AuditLogsHub({ logs, search, category }) {
|
||||
export default function AuditLogsHub({ logs = [], search = '', category = 'all' }) {
|
||||
const [searchTerm, setSearchTerm] = useState(search || '');
|
||||
const [selectedCategory, setSelectedCategory] = useState(category || 'all');
|
||||
|
||||
@ -48,16 +40,16 @@ export default function AuditLogsHub({ logs, search, category }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminLayout title="System Compliance Audit Logs">
|
||||
<Head title="System Audit Logs" />
|
||||
<AdminLayout title="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 */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
{/* Simplified Header */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 border-b border-slate-200 pb-5">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 tracking-tight">Centralized Compliance & Security Audit 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>
|
||||
<h1 className="text-xl font-black text-slate-800 uppercase tracking-tight">System Logs</h1>
|
||||
<p className="text-xs text-slate-500 mt-0.5 font-medium">Detailed history of system events.</p>
|
||||
</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">
|
||||
<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">
|
||||
<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
|
||||
type="text"
|
||||
placeholder="Search logs by IP, actor, message or ticket ID..."
|
||||
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"
|
||||
placeholder="Search logs..."
|
||||
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}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</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">
|
||||
Search Logs
|
||||
<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
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@ -84,10 +76,10 @@ export default function AuditLogsHub({ logs, search, category }) {
|
||||
{[
|
||||
{ label: 'All Logs', val: 'all' },
|
||||
{ label: 'Admin Actions', val: 'admin_action' },
|
||||
{ label: 'OCR Checks', val: 'verification' },
|
||||
{ label: 'Verifications', val: 'verification' },
|
||||
{ label: 'Payments', val: 'payment' },
|
||||
{ label: 'Disputes', val: 'dispute' },
|
||||
{ label: 'User Logins', val: 'user_activity' }
|
||||
{ label: 'Logins', val: 'user_activity' }
|
||||
].map((cat) => (
|
||||
<button
|
||||
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">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-slate-50/50 hover:bg-slate-50/50">
|
||||
<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-500 text-[10px] uppercase tracking-widest h-14">Actor Account</TableHead>
|
||||
<TableHead className="font-bold text-slate-500 text-[10px] uppercase tracking-widest h-14">Activity Description</TableHead>
|
||||
<TableHead className="font-bold text-slate-500 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>
|
||||
<TableRow className="bg-slate-50/50 hover:bg-slate-50/50 border-b border-slate-150">
|
||||
<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-550 text-[10px] uppercase tracking-widest h-14">User</TableHead>
|
||||
<TableHead className="font-bold text-slate-550 text-[10px] uppercase tracking-widest h-14">Action</TableHead>
|
||||
<TableHead className="font-bold text-slate-550 text-[10px] uppercase tracking-widest h-14">IP Address</TableHead>
|
||||
<TableHead className="font-bold text-slate-550 text-[10px] uppercase tracking-widest h-14 text-right pr-8">Time</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{logs && logs.length > 0 ? (
|
||||
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">
|
||||
<div className="flex items-center space-x-2">
|
||||
<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>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs font-semibold text-slate-600 leading-normal max-w-sm">
|
||||
{log.action}
|
||||
<TableCell className="text-xs font-semibold text-slate-600 leading-normal">
|
||||
<div className="max-w-[420px] break-words whitespace-normal py-1">
|
||||
{log.action}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs font-mono font-medium text-slate-400">
|
||||
<div className="flex items-center space-x-1">
|
||||
@ -146,14 +140,14 @@ export default function AuditLogsHub({ logs, search, category }) {
|
||||
<span>{log.ip_address}</span>
|
||||
</div>
|
||||
</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>
|
||||
<TableCell colSpan={5} className="py-20 text-center">
|
||||
<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>
|
||||
</TableRow>
|
||||
)}
|
||||
|
||||
@ -13,119 +13,401 @@ import {
|
||||
ArrowUpRight,
|
||||
User,
|
||||
ShieldCheck,
|
||||
Send
|
||||
Send,
|
||||
Search,
|
||||
ChevronDown,
|
||||
MoreVertical,
|
||||
Clock,
|
||||
Eye,
|
||||
Plus,
|
||||
X,
|
||||
Folder,
|
||||
AlertTriangle,
|
||||
Hourglass
|
||||
} from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
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
|
||||
const [internalNote, setInternalNote] = useState('');
|
||||
const [resolutionAction, setResolutionAction] = useState('refund_employer');
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
if (!internalNote.trim()) return;
|
||||
if (!internalNote.trim() || !selectedTicket) return;
|
||||
|
||||
router.post(`/admin/disputes/${selectedTicket.id}/add-note`, { note: internalNote }, {
|
||||
onSuccess: () => {
|
||||
if (selectedTicket) {
|
||||
selectedTicket.admin_notes += "\n• " + internalNote;
|
||||
}
|
||||
selectedTicket.admin_notes += "\n• " + internalNote;
|
||||
setInternalNote('');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleResolve = (e) => {
|
||||
e.preventDefault();
|
||||
if (e) e.preventDefault();
|
||||
if (!selectedTicket) return;
|
||||
|
||||
router.post(`/admin/disputes/${selectedTicket.id}/resolve`, {
|
||||
resolution: resolutionSummary,
|
||||
action_taken: resolutionAction
|
||||
resolution: internalNote || 'Resolved by mediator',
|
||||
action_taken: 'close_ticket'
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
if (selectedTicket) {
|
||||
selectedTicket.status = 'Resolved';
|
||||
}
|
||||
selectedTicket.status = 'Resolved';
|
||||
setIsWorkspaceOpen(false);
|
||||
setSelectedTicket(null);
|
||||
setInternalNote('');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminLayout title="Disputes Management Hub">
|
||||
<Head title="Disputes Management" />
|
||||
// Filter tickets
|
||||
const filteredTickets = (tickets || []).filter(ticket => {
|
||||
// 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 */}
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 tracking-tight">Contract & Payment Dispute Resolution Center</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>
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 tracking-tight">Dispute Management</h1>
|
||||
<p className="text-sm text-slate-500 mt-1">Handle complaints and resolve disputes between workers and sponsors.</p>
|
||||
</div>
|
||||
|
||||
<button className="inline-flex items-center gap-1.5 px-4 py-2.5 bg-[#0F6E56] hover:bg-[#085041] text-white rounded-xl text-xs font-bold transition-all shadow-md self-end md:self-auto uppercase tracking-wider">
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>New Dispute</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 items-start">
|
||||
{/* Upper Row Metric Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
|
||||
{/* Column 1: Disputes List */}
|
||||
<div className="space-y-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>
|
||||
<div className="space-y-2">
|
||||
{tickets.map((ticket) => (
|
||||
<div
|
||||
key={ticket.id}
|
||||
onClick={() => setSelectedTicket(ticket)}
|
||||
className={`p-4 rounded-xl border cursor-pointer transition-all ${
|
||||
selectedTicket?.id === ticket.id
|
||||
? 'bg-emerald-50/50 border-[#0F6E56] shadow-sm'
|
||||
: 'bg-white border-slate-100 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-mono text-[9px] text-slate-400 font-bold">{ticket.id}</span>
|
||||
<Badge className={`border-none text-[8px] font-black uppercase ${
|
||||
ticket.status === 'Open' ? 'bg-amber-100 text-amber-700' :
|
||||
ticket.status === 'Resolved' ? 'bg-emerald-100 text-emerald-800' : 'bg-blue-100 text-blue-700'
|
||||
}`}>{ticket.status}</Badge>
|
||||
</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>
|
||||
<span>{ticket.created_at}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Card 1: Total Disputes */}
|
||||
<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">Total Disputes</span>
|
||||
<span className="text-2xl font-extrabold text-slate-900 block">36</span>
|
||||
<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">
|
||||
↑ 8% <span className="font-normal text-slate-400 ml-1">from last 30 days</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-3 bg-[#0F6E56]/10 text-[#0F6E56] rounded-2xl">
|
||||
<Scale className="w-6 h-6" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Column 2: Audit Logs & Chat Logs */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{selectedTicket ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* 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>
|
||||
|
||||
{/* Chat Log Audit */}
|
||||
<div className="bg-white border border-slate-200 rounded-3xl p-5 shadow-sm flex flex-col justify-between h-[520px]">
|
||||
<div>
|
||||
<h3 className="text-xs font-black text-slate-800 uppercase tracking-widest flex items-center gap-1">
|
||||
<MessageSquare className="w-4 h-4 text-[#0F6E56]" />
|
||||
{/* 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"
|
||||
>
|
||||
<option value="All Status">All Status</option>
|
||||
<option value="Open">Open</option>
|
||||
<option value="Under Review">Under Review</option>
|
||||
<option value="Waiting Response">Waiting Response</option>
|
||||
<option value="Resolved">Resolved</option>
|
||||
</select>
|
||||
|
||||
|
||||
|
||||
<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="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>
|
||||
|
||||
{/* Table Row Content */}
|
||||
<div className="overflow-x-auto">
|
||||
<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">Dispute ID</th>
|
||||
<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>
|
||||
<span className="font-extrabold text-slate-800 block">
|
||||
{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>
|
||||
</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>
|
||||
|
||||
{/* Conversation thread */}
|
||||
<div className="flex-1 overflow-y-auto space-y-3 my-4 pr-2 max-h-[360px]">
|
||||
{/* Scroll thread */}
|
||||
<div className="flex-1 overflow-y-auto space-y-4 my-4 pr-1 scrollbar-thin">
|
||||
{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 (
|
||||
<div
|
||||
key={i}
|
||||
className={`max-w-[85%] p-3 rounded-2xl text-[11px] font-bold shadow-sm ${
|
||||
isEmployer
|
||||
? 'bg-blue-50 text-blue-900 mr-auto border border-blue-100'
|
||||
: 'bg-teal-50 text-teal-900 ml-auto border border-teal-100'
|
||||
className={`max-w-[90%] p-3 rounded-2xl text-[11px] font-bold shadow-sm ${
|
||||
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-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>
|
||||
@ -135,101 +417,80 @@ export default function DisputesHub({ tickets }) {
|
||||
})}
|
||||
</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
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Evidence & Resolution Tools */}
|
||||
{/* Tools panel */}
|
||||
<div className="space-y-6">
|
||||
|
||||
{/* Evidence Files */}
|
||||
<div className="bg-white border border-slate-200 rounded-3xl p-5 shadow-sm space-y-3">
|
||||
<h3 className="text-xs font-black text-slate-800 uppercase tracking-widest flex items-center gap-1">
|
||||
<FileText className="w-4 h-4 text-[#0F6E56]" />
|
||||
<span>Evidence Files</span>
|
||||
<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.5">
|
||||
<FileText className="w-4.5 h-4.5 text-[#0F6E56]" />
|
||||
<span>Case Evidence Files</span>
|
||||
</h3>
|
||||
{selectedTicket.evidence?.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{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 className="space-y-2">
|
||||
{selectedTicket.evidence?.map((file, i) => (
|
||||
<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>
|
||||
<a href={file.url} className="text-[#0F6E56] hover:underline flex items-center gap-0.5">
|
||||
<span>View File</span>
|
||||
<ArrowUpRight className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[10px] text-slate-400 font-semibold uppercase">No attachment proofs uploaded.</p>
|
||||
)}
|
||||
<a href={file.url} className="text-[#0F6E56] hover:underline flex items-center gap-0.5">
|
||||
<span>View</span>
|
||||
<ArrowUpRight className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Admin Notes */}
|
||||
<div className="bg-yellow-50/50 border border-yellow-200 rounded-3xl p-5 shadow-sm space-y-3">
|
||||
<h3 className="text-xs font-black text-yellow-800 uppercase tracking-widest">Internal Compliance Notes</h3>
|
||||
<p className="text-[11px] text-slate-600 bg-white p-3 rounded-xl border border-yellow-100 font-semibold leading-relaxed whitespace-pre-line">
|
||||
{selectedTicket.admin_notes}
|
||||
</p>
|
||||
<form onSubmit={handleAddNote} className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
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..."
|
||||
{/* Simplified Action Panel */}
|
||||
<div className="bg-white border border-slate-200 rounded-2xl p-5 shadow-sm space-y-4">
|
||||
<div>
|
||||
<h3 className="text-xs font-black text-slate-800 uppercase tracking-widest flex items-center gap-2">
|
||||
<MessageSquare className="w-4 h-4 text-[#0F6E56]" />
|
||||
<span>Mediator Reply & Actions</span>
|
||||
</h3>
|
||||
<p className="text-[10px] text-slate-400 font-semibold mt-1">Send a message to both parties or resolve the dispute.</p>
|
||||
</div>
|
||||
|
||||
<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}
|
||||
onChange={e => setInternalNote(e.target.value)}
|
||||
/>
|
||||
<button type="submit" className="p-2 bg-[#0F6E56] text-white rounded-xl">
|
||||
<Send className="w-4.5 h-4.5" />
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
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"
|
||||
>
|
||||
Send Reply
|
||||
</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>
|
||||
</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>
|
||||
<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>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-3xl border p-20 text-center text-slate-400 font-bold">
|
||||
Select a dispute ticket from the left panel.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@ -2,21 +2,13 @@ import React, { useState } from 'react';
|
||||
import { Head, router } from '@inertiajs/react';
|
||||
import AdminLayout from '@/Layouts/AdminLayout';
|
||||
import {
|
||||
BellRing,
|
||||
Send,
|
||||
Smartphone,
|
||||
MessageCircle,
|
||||
CheckCircle,
|
||||
BarChart3,
|
||||
Users,
|
||||
AlertTriangle,
|
||||
History,
|
||||
Sparkles,
|
||||
TrendingUp
|
||||
Sparkles
|
||||
} from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
export default function NotificationsCampaigns({ campaigns, whatsapp_templates }) {
|
||||
export default function NotificationsCampaigns({ campaigns = [] }) {
|
||||
const [channel, setChannel] = useState('push');
|
||||
const [recipients, setRecipients] = useState('All Active Workers');
|
||||
const [title, setTitle] = useState('');
|
||||
@ -38,174 +30,121 @@ export default function NotificationsCampaigns({ campaigns, whatsapp_templates }
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminLayout title="Campaigns & Notifications Hub">
|
||||
<Head title="Campaigns & Alerts" />
|
||||
<AdminLayout title="Notifications">
|
||||
<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 */}
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 tracking-tight">System Campaign Broadcasting Center</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>
|
||||
{/* Simplified Header */}
|
||||
<div className="border-b border-slate-200 pb-5">
|
||||
<h1 className="text-xl font-black text-slate-800 uppercase tracking-tight">Send Notification</h1>
|
||||
<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 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 */}
|
||||
<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="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-black text-slate-800 uppercase tracking-widest flex items-center gap-1">
|
||||
<Sparkles className="w-4 h-4 text-[#0F6E56]" />
|
||||
<span>Direct Campaign Composer</span>
|
||||
</h3>
|
||||
<p className="text-[10px] text-slate-400 font-semibold mt-1">Schedules immediate or recurring broadcasts</p>
|
||||
</div>
|
||||
<Badge className="bg-emerald-50 text-emerald-700 border-none font-bold uppercase text-[8px] tracking-wider">SMS Gateway: Active</Badge>
|
||||
</div>
|
||||
|
||||
<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="space-y-1">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Select Channel</label>
|
||||
<select
|
||||
className="w-full bg-slate-50 border rounded-xl px-3 py-2.5 outline-none cursor-pointer focus:bg-white"
|
||||
value={channel}
|
||||
onChange={e => setChannel(e.target.value)}
|
||||
>
|
||||
<option value="push">Native App Push Notification</option>
|
||||
<option value="whatsapp">WhatsApp Business API</option>
|
||||
<option value="both">Both (Multi-channel coverage)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Target User Cohort</label>
|
||||
<select
|
||||
className="w-full bg-slate-50 border rounded-xl px-3 py-2.5 outline-none cursor-pointer focus:bg-white"
|
||||
value={recipients}
|
||||
onChange={e => setRecipients(e.target.value)}
|
||||
>
|
||||
<option value="All Active Workers">All Active Workers (1,250 users)</option>
|
||||
<option value="Unverified Workers (Morocco & Philippines)">Unverified Workers Pool (142 users)</option>
|
||||
<option value="Premium Employers">Premium Employers only (312 users)</option>
|
||||
<option value="All Employers">All Registered Employers (380 users)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Notification Title</label>
|
||||
<input
|
||||
type="text"
|
||||
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"
|
||||
placeholder="e.g., Emirates ID Verification Required"
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Notification Text Message</label>
|
||||
<textarea
|
||||
rows="3"
|
||||
required
|
||||
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..."
|
||||
value={message}
|
||||
onChange={e => setMessage(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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" />
|
||||
<span>Broadcast System Campaign</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Section 2: Campaigns History */}
|
||||
<div className="bg-white border border-slate-200 rounded-3xl p-6 shadow-sm overflow-hidden">
|
||||
<div className="mb-4">
|
||||
<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" />
|
||||
<span>Broadcast Campaign Logs</span>
|
||||
</h3>
|
||||
<p className="text-[10px] text-slate-400 font-semibold mt-0.5">Logs of recently delivered campaigns</p>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-slate-100">
|
||||
{campaigns.map((camp) => (
|
||||
<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="flex items-center space-x-2">
|
||||
<Badge className="bg-blue-50 text-blue-700 border-none font-bold uppercase text-[8px]">{camp.channel}</Badge>
|
||||
<span className="text-[10px] text-slate-400 font-semibold">Cohort: {camp.recipient_type}</span>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<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">
|
||||
{camp.delivery_rate} Delivered • {camp.clicks}
|
||||
</div>
|
||||
</div>
|
||||
</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">
|
||||
{/* Message Composer */}
|
||||
<div className="bg-white border border-slate-200 rounded-3xl p-6 shadow-sm">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<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 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]" />
|
||||
<span>New Notification Alert</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>
|
||||
))}
|
||||
<p className="text-[10px] text-slate-400 font-semibold mt-1">Send a dynamic message immediately</p>
|
||||
</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>
|
||||
<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="space-y-1">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Message Channel</label>
|
||||
<select
|
||||
className="w-full bg-slate-50 border rounded-xl px-3 py-2.5 outline-none cursor-pointer focus:bg-white"
|
||||
value={channel}
|
||||
onChange={e => setChannel(e.target.value)}
|
||||
>
|
||||
<option value="push">App Message</option>
|
||||
<option value="whatsapp">WhatsApp Message</option>
|
||||
<option value="both">Both Channels</option>
|
||||
</select>
|
||||
</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 className="space-y-1">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Send To</label>
|
||||
<select
|
||||
className="w-full bg-slate-50 border rounded-xl px-3 py-2.5 outline-none cursor-pointer focus:bg-white"
|
||||
value={recipients}
|
||||
onChange={e => setRecipients(e.target.value)}
|
||||
>
|
||||
<option value="All Active Workers">All Workers</option>
|
||||
<option value="All Employers">All Sponsors</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Message Title</label>
|
||||
<input
|
||||
type="text"
|
||||
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"
|
||||
placeholder="e.g. Account Update Alert"
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Message Text</label>
|
||||
<textarea
|
||||
rows="3"
|
||||
required
|
||||
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..."
|
||||
value={message}
|
||||
onChange={e => setMessage(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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" />
|
||||
<span>Send Message</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Recently Sent Logs */}
|
||||
<div className="bg-white border border-slate-200 rounded-3xl p-6 shadow-sm overflow-hidden">
|
||||
<div className="mb-4">
|
||||
<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" />
|
||||
<span>History</span>
|
||||
</h3>
|
||||
<p className="text-[10px] text-slate-400 font-semibold mt-0.5">Logs of recently sent alerts</p>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-slate-100">
|
||||
{campaigns.map((camp) => (
|
||||
<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="flex items-center space-x-2">
|
||||
<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">To: {camp.recipient_type}</span>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<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">
|
||||
Sent Successfully
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</AdminLayout>
|
||||
|
||||
@ -13,7 +13,14 @@ import {
|
||||
UserX,
|
||||
Settings,
|
||||
FileText,
|
||||
MessageSquare
|
||||
MessageSquare,
|
||||
Bell,
|
||||
Calendar,
|
||||
Search,
|
||||
ChevronDown,
|
||||
MoreVertical,
|
||||
Clock,
|
||||
Shield
|
||||
} from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
@ -29,142 +36,352 @@ export default function SafetyHub({ reports, rules, moderation_queue }) {
|
||||
const [resolutionAction, setResolutionAction] = useState('warn');
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
if (!selectedReport) return;
|
||||
|
||||
router.post(`/admin/safety/reports/${selectedReport.id}/resolve`, {
|
||||
action: resolutionAction,
|
||||
admin_notes: adminNotes
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
setIsDialogOpen(false);
|
||||
setSelectedReport(null);
|
||||
setAdminNotes('');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleModQueue = (id, action) => {
|
||||
router.post(`/admin/safety/reports/${id}/resolve`, { action }, {
|
||||
onSuccess: () => {
|
||||
setIsDialogOpen(false);
|
||||
}
|
||||
});
|
||||
};
|
||||
// Filter reports based on active tab, search query, type, and status
|
||||
const filteredReports = (reports || []).filter(report => {
|
||||
// Tab filter
|
||||
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 (
|
||||
<AdminLayout title="Safety & Moderation Center">
|
||||
<Head title="Safety & Moderation" />
|
||||
<AdminLayout title="Content 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 */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 tracking-tight">Trust & Safety Escalation Center</h1>
|
||||
<p className="text-xs text-gray-500 mt-0.5">Manage user abuse reports, content moderation queues, and customize automatic fraud filters.</p>
|
||||
<h1 className="text-2xl font-bold text-slate-900 tracking-tight">Content Moderation</h1>
|
||||
<p className="text-sm text-slate-500 mt-1">Review and take action on reported content, users and reviews.</p>
|
||||
</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">
|
||||
<Settings className="w-4 h-4" />
|
||||
<span>System Rule Rules</span>
|
||||
|
||||
{/* Header Controls */}
|
||||
<div className="flex items-center gap-3 self-end md:self-auto">
|
||||
<button className="relative p-2.5 bg-white border border-slate-200 rounded-xl hover:bg-slate-50 transition-colors shadow-sm text-slate-600">
|
||||
<Bell className="w-5 h-5" />
|
||||
<span className="absolute top-1.5 right-1.5 w-4 h-4 bg-red-500 text-white rounded-full text-[9px] font-bold flex items-center justify-center">12</span>
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2 px-4 py-2.5 bg-white border border-slate-200 rounded-xl hover:bg-slate-50 transition-colors shadow-sm text-xs font-semibold text-slate-700 cursor-pointer">
|
||||
<Calendar className="w-4 h-4 text-slate-400" />
|
||||
<span>May 13, 2025 - Jun 11, 2025</span>
|
||||
<ChevronDown className="w-4 h-4 text-slate-400" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div 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 */}
|
||||
<div className="lg:col-span-2 space-y-8">
|
||||
{/* Card 1: Total Reports */}
|
||||
<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">Total Reports</span>
|
||||
<span className="text-2xl font-extrabold text-slate-900 block">128</span>
|
||||
<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">
|
||||
↑ 12% <span className="font-normal text-slate-400 ml-1">from last 30 days</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-3 bg-[#0F6E56]/10 text-[#0F6E56] rounded-2xl">
|
||||
<Flag className="w-6 h-6" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Section 1: Abuse Reports */}
|
||||
<div className="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">Active Incident Reports</h3>
|
||||
<p className="text-[10px] text-slate-400 mt-0.5">Escalated complaints filed by employers or workers</p>
|
||||
</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>
|
||||
{/* Card 2: Pending 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">Pending Review</span>
|
||||
<span className="text-2xl font-extrabold text-slate-900 block">42</span>
|
||||
<span className="text-[11px] text-amber-600 font-bold block">Requires attention</span>
|
||||
</div>
|
||||
<div className="p-3 bg-amber-50 text-amber-600 rounded-2xl">
|
||||
<Clock className="w-6 h-6" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-slate-100">
|
||||
{reports.map((report) => (
|
||||
<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-2 flex-1">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge className={`border-none text-[8px] font-black uppercase ${
|
||||
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>
|
||||
<h4 className="text-sm font-black text-slate-900">{report.reason}</h4>
|
||||
<p className="text-slate-500 font-medium mt-1 leading-relaxed">{report.description}</p>
|
||||
</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 className="flex flex-col items-end gap-2">
|
||||
<span className="text-[9px] text-slate-400 font-mono">{report.created_at}</span>
|
||||
<button
|
||||
onClick={() => { setSelectedReport(report); setIsDialogOpen(true); }}
|
||||
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"
|
||||
>
|
||||
Investigate
|
||||
</button>
|
||||
</div>
|
||||
</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">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
|
||||
key={tab}
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
{tab}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Section 2: Content Moderation Queue */}
|
||||
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden">
|
||||
<div className="p-5 border-b border-slate-100 bg-slate-50/20">
|
||||
{/* 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>
|
||||
|
||||
{/* Table Row Content */}
|
||||
<div className="overflow-x-auto">
|
||||
<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>
|
||||
<p className="text-[10px] text-slate-400 mt-0.5">Bio updates or pictures flagged for manual review</p>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-slate-100">
|
||||
{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>
|
||||
<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>
|
||||
<Badge className="mt-2 bg-amber-50 text-amber-700 border-none font-bold uppercase tracking-wider text-[8px]">{item.flag}</Badge>
|
||||
</div>
|
||||
<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">
|
||||
{item.content.startsWith('http') ? (
|
||||
<img src={item.content} alt="Flagged asset" className="h-16 rounded border" />
|
||||
) : item.content}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</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">
|
||||
{moderation_queue.map((item) => (
|
||||
<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>
|
||||
<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>
|
||||
<Badge className="mt-2 bg-red-50 text-red-700 border-none font-bold uppercase tracking-wider text-[8px]">{item.flag}</Badge>
|
||||
</div>
|
||||
<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">
|
||||
{item.content.startsWith('http') ? (
|
||||
<img src={item.content} alt="Flagged asset" className="h-16 rounded border" />
|
||||
) : item.content}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<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">
|
||||
Approve Content
|
||||
</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">
|
||||
Reject / Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Column 3: Auto-Flag Detection Rules */}
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white p-5 border border-slate-200 rounded-2xl shadow-sm space-y-4">
|
||||
{/* Auto-Detection Rules */}
|
||||
<div className="bg-white p-5 border border-slate-200 rounded-2xl shadow-sm flex flex-col justify-between">
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<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>
|
||||
@ -183,30 +400,21 @@ export default function SafetyHub({ reports, rules, moderation_queue }) {
|
||||
</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">
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>Create Custom Rule</span>
|
||||
</button>
|
||||
</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>
|
||||
<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" />
|
||||
<span>Create Custom Rule</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Investigation Dialog Modal */}
|
||||
{/* Investigation & Action Dialog Modal */}
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogContent className="max-w-xl bg-white p-6 rounded-[24px] border-none shadow-2xl font-sans">
|
||||
<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" />
|
||||
<span>Incident Investigation: <span className="font-mono text-slate-400">{selectedReport?.id}</span></span>
|
||||
</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">
|
||||
<div className="space-y-1">
|
||||
<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 className="space-y-1">
|
||||
<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 className="space-y-1">
|
||||
<label className="text-[9px] font-black text-slate-400 uppercase tracking-widest">Select Resolution Action</label>
|
||||
<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}
|
||||
onChange={e => setResolutionAction(e.target.value)}
|
||||
>
|
||||
@ -242,7 +452,7 @@ export default function SafetyHub({ reports, rules, moderation_queue }) {
|
||||
<textarea
|
||||
rows="3"
|
||||
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}
|
||||
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">
|
||||
<button
|
||||
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
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
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
|
||||
</button>
|
||||
|
||||
@ -2,21 +2,15 @@ import React, { useState } from 'react';
|
||||
import { Head, router } from '@inertiajs/react';
|
||||
import AdminLayout from '@/Layouts/AdminLayout';
|
||||
import {
|
||||
Search,
|
||||
FileText,
|
||||
Eye,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Clock,
|
||||
Edit2,
|
||||
ZoomIn,
|
||||
X,
|
||||
AlertTriangle,
|
||||
ShieldCheck,
|
||||
Edit3,
|
||||
History,
|
||||
Sparkles,
|
||||
CheckSquare
|
||||
Sparkles
|
||||
} from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
@ -29,7 +23,6 @@ import {
|
||||
export default function Verifications({ verifications, status }) {
|
||||
const [selectedItem, setSelectedItem] = useState(null);
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [lightboxImage, setLightboxImage] = useState(null);
|
||||
|
||||
// Vetting form overrides
|
||||
const [ocrOverrides, setOcrOverrides] = useState({});
|
||||
@ -37,7 +30,7 @@ export default function Verifications({ verifications, status }) {
|
||||
const [rejectionReason, setRejectionReason] = useState('');
|
||||
const [showRejectionForm, setShowRejectionForm] = useState(false);
|
||||
|
||||
// Local mock verification queue containing OCR score mismatches
|
||||
// Local queue of workers to verify
|
||||
const localQueue = [
|
||||
{
|
||||
id: 101,
|
||||
@ -47,12 +40,9 @@ export default function Verifications({ verifications, status }) {
|
||||
processed_at: '2026-05-23 14:15',
|
||||
status: 'approved',
|
||||
confidence: 98,
|
||||
verification_method: 'Auto-OCR',
|
||||
document_type: 'Passport & Visa Scan',
|
||||
verification_method: 'Auto',
|
||||
document_type: 'Passport',
|
||||
warnings: [],
|
||||
document_images: [
|
||||
'https://images.unsplash.com/photo-1544717305-2782549b5136?q=80&w=600&auto=format&fit=crop',
|
||||
],
|
||||
ocr_data: {
|
||||
'Name': 'Fatima Zahra',
|
||||
'DOB': '1992-08-14',
|
||||
@ -69,12 +59,9 @@ export default function Verifications({ verifications, status }) {
|
||||
processed_at: '2026-05-23 11:30',
|
||||
status: 'flagged',
|
||||
confidence: 58,
|
||||
verification_method: 'Auto-OCR',
|
||||
document_type: 'Passport Scan',
|
||||
verification_method: 'Auto',
|
||||
document_type: 'Passport',
|
||||
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: {
|
||||
'Name': 'Amina Diop',
|
||||
'DOB': '1994-12-01',
|
||||
@ -91,12 +78,9 @@ export default function Verifications({ verifications, status }) {
|
||||
processed_at: '2026-05-22 16:40',
|
||||
status: 'flagged',
|
||||
confidence: 62,
|
||||
verification_method: 'Auto-OCR',
|
||||
verification_method: 'Auto',
|
||||
document_type: 'Visa scan details',
|
||||
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: {
|
||||
'Name': 'Siti Aminah',
|
||||
'DOB': '1988-03-25',
|
||||
@ -135,24 +119,24 @@ export default function Verifications({ verifications, status }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminLayout title="Vetting & OCR Review Hub">
|
||||
<Head title="OCR Verifications" />
|
||||
<AdminLayout title="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 */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
{/* Simplified Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 border-b border-slate-200 pb-5">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 tracking-tight">OCR Vetting Queue & Verification System</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>
|
||||
<h1 className="text-xl font-black text-slate-800 uppercase tracking-tight">Worker Verifications</h1>
|
||||
<p className="text-xs text-slate-500 mt-0.5 font-medium">Verify workers, check passports, and override data inputs.</p>
|
||||
</div>
|
||||
|
||||
{/* 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: 'Auto-Approved (High Confidence)', value: 'approved' },
|
||||
{ label: 'Vetting Flags (Low Confidence)', value: 'rejected' },
|
||||
{ label: 'All', value: 'all' },
|
||||
{ label: 'Approved', value: 'approved' },
|
||||
{ label: 'Flagged', value: 'rejected' },
|
||||
].map((tab) => (
|
||||
<button
|
||||
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 ${
|
||||
(status || 'all') === tab.value
|
||||
? 'bg-white text-[#0F6E56] shadow-sm'
|
||||
: 'text-slate-600 hover:text-slate-900'
|
||||
: 'text-slate-650 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
{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="bg-white p-5 border border-slate-200 rounded-2xl flex items-center justify-between shadow-sm">
|
||||
<div>
|
||||
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest block">System Auto-Approve Rate</span>
|
||||
<span className="text-2xl font-black text-slate-800">92.4%</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 mt-1">92.4%</span>
|
||||
</div>
|
||||
<div className="w-10 h-10 bg-emerald-50 rounded-xl flex items-center justify-center">
|
||||
<ShieldCheck className="w-5 h-5 text-emerald-600" />
|
||||
<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" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white p-5 border border-slate-200 rounded-2xl flex items-center justify-between shadow-sm">
|
||||
<div>
|
||||
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest block">OCR Average Confidence Score</span>
|
||||
<span className="text-2xl font-black text-blue-600">91.8%</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 mt-1">91.8%</span>
|
||||
</div>
|
||||
<div className="w-10 h-10 bg-blue-50 rounded-xl flex items-center justify-center">
|
||||
<Sparkles className="w-5 h-5 text-blue-600" />
|
||||
<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" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white p-5 border border-slate-200 rounded-2xl flex items-center justify-between shadow-sm">
|
||||
<div>
|
||||
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest block">Vetting Queue Flags</span>
|
||||
<span className="text-2xl font-black text-amber-600">2 Pending review</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 mt-1">2 Pending</span>
|
||||
</div>
|
||||
<div className="w-10 h-10 bg-amber-50 rounded-xl flex items-center justify-center">
|
||||
<AlertTriangle className="w-5 h-5 text-amber-600" />
|
||||
<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" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<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">Passport ID</th>
|
||||
<th className="py-4 px-6">OCR Confidence Score</th>
|
||||
<th className="py-4 px-6">Vetting Warnings</th>
|
||||
<th className="py-4 px-6">Confidence Score</th>
|
||||
<th className="py-4 px-6">Warnings</th>
|
||||
<th className="py-4 px-6">Processed Date</th>
|
||||
<th className="py-4 px-6 text-right">Action</th>
|
||||
</tr>
|
||||
@ -221,13 +205,13 @@ export default function Verifications({ verifications, status }) {
|
||||
<td className="py-4 px-6">
|
||||
<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>
|
||||
</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">
|
||||
<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
|
||||
className={`h-full rounded-full ${
|
||||
item.confidence > 90 ? 'bg-emerald-500' :
|
||||
@ -244,15 +228,15 @@ export default function Verifications({ verifications, status }) {
|
||||
</td>
|
||||
<td className="py-4 px-6">
|
||||
{item.warnings.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{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">
|
||||
<AlertTriangle className="w-2.5 h-2.5 mr-1" /> {w}
|
||||
<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">
|
||||
{w}
|
||||
</span>
|
||||
))}
|
||||
</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 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)}
|
||||
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'
|
||||
? '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'
|
||||
}`}
|
||||
>
|
||||
{item.status === 'flagged' ? 'Verify OCR' : 'Audit Log'}
|
||||
{item.status === 'flagged' ? 'Verify' : 'Logs'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
@ -277,117 +261,68 @@ export default function Verifications({ verifications, status }) {
|
||||
</div>
|
||||
|
||||
{/* Audit logs details grid */}
|
||||
<div className="bg-slate-50 rounded-2xl p-5 border border-slate-200">
|
||||
<h3 className="text-xs font-black text-slate-800 uppercase tracking-widest flex items-center gap-1.5 mb-3">
|
||||
<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 border-b border-slate-100 pb-3">
|
||||
<History className="w-4 h-4 text-slate-600" />
|
||||
<span>OCR Vetting Action Audit Logs</span>
|
||||
<span>Verification History</span>
|
||||
</h3>
|
||||
<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 14:15', text: 'System OCR Passport auto-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 15:20', text: 'Admin manual approval completed for Leila Bekri passport verification', ip: '192.168.1.5' },
|
||||
{ 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: 'Auto verification flagged passport scan of Grace Omondi due to blurred signature scan', ip: 'Auto-System' },
|
||||
].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">
|
||||
<Clock className="w-4 h-4 text-slate-400" />
|
||||
<span>{log.text}</span>
|
||||
</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>
|
||||
|
||||
{/* Vetting Vetting Audit Dialog */}
|
||||
{/* Verification Audit Dialog - Simplified & Width Optimized */}
|
||||
<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">
|
||||
<DialogTitle className="text-lg font-black text-slate-800 flex items-center justify-between tracking-tight">
|
||||
<span className="flex items-center gap-2">
|
||||
<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>
|
||||
<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 ${
|
||||
selectedItem?.confidence > 80
|
||||
? '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 className="border-none bg-[#0F6E56] text-white px-3.5 py-1.5 font-black text-[9px] uppercase tracking-wider rounded-lg shadow-sm">
|
||||
<span>Score: {selectedItem?.confidence}%</span>
|
||||
</Badge>
|
||||
</div>
|
||||
</DialogTitle>
|
||||
</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 */}
|
||||
<div className="space-y-5">
|
||||
<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>
|
||||
{/* Warnings Box - Rendered Inline */}
|
||||
{selectedItem?.warnings?.length > 0 && (
|
||||
<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">
|
||||
<AlertTriangle className="w-4 h-4 text-rose-600 animate-bounce" />
|
||||
<span>Verification Warnings</span>
|
||||
</span>
|
||||
<ul className="space-y-2 text-xs font-bold text-rose-950">
|
||||
{selectedItem.warnings.map((w, idx) => (
|
||||
<li key={idx} className="flex items-center gap-2 bg-white px-3 py-2 rounded-xl border border-rose-100 shadow-xs">
|
||||
<div className="w-1.5 h-1.5 bg-rose-500 rounded-full flex-shrink-0" />
|
||||
<span>{w}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</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">
|
||||
<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" />
|
||||
<span>Suspicious Document Vetting Warning</span>
|
||||
</span>
|
||||
<ul className="space-y-2 text-xs font-bold text-rose-950">
|
||||
{selectedItem.warnings.map((w, idx) => (
|
||||
<li key={idx} className="flex items-center gap-2 bg-white px-3 py-2 rounded-xl border border-rose-100 shadow-xs">
|
||||
<div className="w-1.5 h-1.5 bg-rose-500 rounded-full flex-shrink-0" />
|
||||
<span>{w}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</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 */}
|
||||
<div className="bg-white p-5 border border-slate-200 rounded-2xl shadow-sm space-y-6 flex flex-col justify-between">
|
||||
{/* OCR Data Extraction Form - Styled Cleanly */}
|
||||
<div className="bg-white p-5 border border-slate-200 rounded-2xl shadow-sm space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between border-b border-slate-100 pb-2">
|
||||
<h3 className="text-xs font-black text-slate-700 uppercase tracking-widest">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]"
|
||||
>
|
||||
<Edit3 className="w-3.5 h-3.5" />
|
||||
<span>{isEditingOcr ? 'Cancel Edit' : 'Edit OCR Values'}</span>
|
||||
<span>{isEditingOcr ? 'Cancel' : 'Edit values'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
{Object.entries(ocrOverrides).map(([key, value]) => (
|
||||
<div key={key} className="space-y-1.5">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest block">{key}</label>
|
||||
{isEditingOcr ? (
|
||||
<input
|
||||
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}
|
||||
onChange={e => {
|
||||
const newVal = e.target.value;
|
||||
@ -427,11 +362,11 @@ export default function Verifications({ verifications, status }) {
|
||||
{/* Manual Rejection reason input */}
|
||||
{showRejectionForm ? (
|
||||
<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
|
||||
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"
|
||||
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}
|
||||
onChange={e => setRejectionReason(e.target.value)}
|
||||
/>
|
||||
@ -440,11 +375,11 @@ export default function Verifications({ verifications, status }) {
|
||||
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"
|
||||
>
|
||||
Confirm Rejection
|
||||
Confirm
|
||||
</button>
|
||||
<button
|
||||
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
|
||||
</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">
|
||||
<button
|
||||
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" />
|
||||
<span>Approve Verification</span>
|
||||
<span>Approve</span>
|
||||
</button>
|
||||
<button
|
||||
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" />
|
||||
<span>Flag Rejection</span>
|
||||
<span>Reject</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@ -472,26 +407,6 @@ export default function Verifications({ verifications, status }) {
|
||||
</div>
|
||||
</DialogContent>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -84,6 +84,7 @@
|
||||
|
||||
// Worker and Candidate Pipeline Management
|
||||
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::post('/employers/candidates/{id}/hire', [\App\Http\Controllers\Api\EmployerWorkerController::class, 'hireCandidate']);
|
||||
Route::post('/employers/candidates/hire', [\App\Http\Controllers\Api\EmployerWorkerController::class, 'hireCandidate']);
|
||||
|
||||
@ -19,7 +19,7 @@ export default defineConfig({
|
||||
tailwindcss(),
|
||||
],
|
||||
server: {
|
||||
host: 'localhost',
|
||||
host: '192.168.29.131',
|
||||
watch: {
|
||||
ignored: ['**/storage/framework/views/**'],
|
||||
},
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user