mohan #5
@ -14,48 +14,23 @@ class AdminExtraController extends Controller
|
|||||||
*/
|
*/
|
||||||
public function safety()
|
public function safety()
|
||||||
{
|
{
|
||||||
$reports = [
|
$reports = DB::table('moderation_reports')->get()->map(function ($report) {
|
||||||
[
|
return [
|
||||||
'id' => 'REP-101',
|
'id' => $report->id,
|
||||||
'reporter' => 'Marina Cleaners (Employer)',
|
'type' => $report->type,
|
||||||
'reported_user' => 'Grace Omondi (Worker)',
|
'reported_user_name' => $report->reported_user_name,
|
||||||
'reason' => 'Profile details mismatch / False advertising',
|
'reported_user_role' => $report->reported_user_role,
|
||||||
'description' => 'The worker claims to have 4 years experience in childcare, but when interviewed, they stated they only did housekeeping.',
|
'reported_user_avatar' => $report->reported_user_avatar,
|
||||||
'status' => 'Pending Review',
|
'reported_by_name' => $report->reported_by_name,
|
||||||
'severity' => 'Medium',
|
'reported_by_role' => $report->reported_by_role,
|
||||||
'created_at' => '2026-05-22 14:30',
|
'reported_by_avatar' => $report->reported_by_avatar,
|
||||||
],
|
'reason' => $report->reason,
|
||||||
[
|
'priority' => $report->priority,
|
||||||
'id' => 'REP-102',
|
'status' => $report->status,
|
||||||
'reporter' => 'Siti Aminah (Worker)',
|
'description' => $report->description,
|
||||||
'reported_user' => 'Golden Hospitality (Employer)',
|
'reported_at' => date('M d, Y h:i A', strtotime($report->reported_at)),
|
||||||
'reason' => 'Abusive message exchange',
|
|
||||||
'description' => 'Employer used inappropriate language during direct chat after worker declined an weekend offer.',
|
|
||||||
'status' => 'Escalated',
|
|
||||||
'severity' => 'High',
|
|
||||||
'created_at' => '2026-05-21 09:15',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'id' => 'REP-103',
|
|
||||||
'reporter' => 'System (Auto-Flag)',
|
|
||||||
'reported_user' => 'Amina Diop (Worker)',
|
|
||||||
'reason' => 'Suspicious document signature mismatch',
|
|
||||||
'description' => 'OCR matching detected that the signature and name on the Passport does not match the registration name.',
|
|
||||||
'status' => 'Under Investigation',
|
|
||||||
'severity' => 'High',
|
|
||||||
'created_at' => '2026-05-20 18:45',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'id' => 'REP-104',
|
|
||||||
'reporter' => 'Hassan Al Hosani (Employer)',
|
|
||||||
'reported_user' => 'Maria Santos (Worker)',
|
|
||||||
'reason' => 'No-show for interview',
|
|
||||||
'description' => 'Worker confirmed three consecutive video interviews but failed to join without notice.',
|
|
||||||
'status' => 'Resolved',
|
|
||||||
'severity' => 'Low',
|
|
||||||
'created_at' => '2026-05-18 11:00',
|
|
||||||
]
|
|
||||||
];
|
];
|
||||||
|
});
|
||||||
|
|
||||||
$rules = [
|
$rules = [
|
||||||
['id' => 1, 'name' => 'IP-Range Duplication Alert', 'trigger' => 'More than 3 worker accounts from the same IP', 'status' => 'Active'],
|
['id' => 1, 'name' => 'IP-Range Duplication Alert', 'trigger' => 'More than 3 worker accounts from the same IP', 'status' => 'Active'],
|
||||||
@ -100,6 +75,24 @@ public function resolveSafetyReport(Request $request, $id)
|
|||||||
'admin_notes' => 'nullable|string'
|
'admin_notes' => 'nullable|string'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
DB::table('moderation_reports')
|
||||||
|
->where('id', $id)
|
||||||
|
->update([
|
||||||
|
'status' => 'Resolved',
|
||||||
|
'admin_notes' => $request->admin_notes ?: 'Resolved by administrator',
|
||||||
|
'updated_at' => now()
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Dynamically log event
|
||||||
|
DB::table('audit_logs')->insert([
|
||||||
|
'category' => 'admin_action',
|
||||||
|
'user' => 'SuperAdmin (support@marketplace.com)',
|
||||||
|
'action' => 'Resolved Safety Report ' . $id . ' with action: ' . strtoupper($request->action) . '. Notes: ' . ($request->admin_notes ?: 'none'),
|
||||||
|
'ip_address' => $request->ip() ?: '127.0.0.1',
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now()
|
||||||
|
]);
|
||||||
|
|
||||||
return back()->with('success', "Safety Report {$id} has been resolved successfully with action: " . strtoupper($request->action));
|
return back()->with('success', "Safety Report {$id} has been resolved successfully with action: " . strtoupper($request->action));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -108,61 +101,45 @@ public function resolveSafetyReport(Request $request, $id)
|
|||||||
*/
|
*/
|
||||||
public function disputes()
|
public function disputes()
|
||||||
{
|
{
|
||||||
$tickets = [
|
$tickets = DB::table('disputes')->get()->map(function ($dispute) {
|
||||||
[
|
// Decode or initialize logs dynamically
|
||||||
'id' => 'DISP-701',
|
$logs = [];
|
||||||
'employer' => 'Marina Cleaners',
|
if ($dispute->chat_logs) {
|
||||||
'worker' => 'Grace Omondi',
|
$logs = json_decode($dispute->chat_logs, true) ?: [];
|
||||||
'subject' => 'Advance Payment Refund Dispute',
|
} else {
|
||||||
'amount_aed' => 499,
|
$logs = [
|
||||||
'status' => 'Open',
|
['sender' => $dispute->party_two_name, 'time' => '10:05 AM', 'message' => "Regarding the dispute on {$dispute->type}: we need to resolve this issue about {$dispute->reason} as soon as possible."],
|
||||||
'created_at' => '2026-05-20',
|
['sender' => $dispute->party_one_name, 'time' => '10:12 AM', 'message' => 'I have already explained my side. Please check the contract agreement details.'],
|
||||||
'chat_logs' => [
|
['sender' => $dispute->party_two_name, 'time' => '10:14 AM', 'message' => 'That is not what we agreed upon. Let the admin mediator review the records.']
|
||||||
['sender' => 'Employer', 'time' => '10:05 AM', 'message' => 'I paid you the advance of 499 AED for direct transport, but you did not report to work.'],
|
|
||||||
['sender' => 'Worker', 'time' => '10:12 AM', 'message' => 'The agency driver was delayed and did not bring the entry permit paper, so I could not travel.'],
|
|
||||||
['sender' => 'Employer', 'time' => '10:14 AM', 'message' => 'That is your responsibility, I need the refund or immediate attendance.'],
|
|
||||||
['sender' => 'Worker', 'time' => '10:20 AM', 'message' => 'I do not have the money anymore, the transport ticket was already bought and non-refundable.'],
|
|
||||||
],
|
|
||||||
'evidence' => [
|
|
||||||
['type' => 'Receipt', 'name' => 'payment_slip.pdf', 'url' => '#'],
|
|
||||||
['type' => 'Agreement', 'name' => 'hiring_agreement_signed.pdf', 'url' => '#'],
|
|
||||||
],
|
|
||||||
'admin_notes' => 'Awaiting confirmation of driver delay from the visa coordinator.'
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'id' => 'DISP-702',
|
|
||||||
'employer' => 'Al Barari Real Estate',
|
|
||||||
'worker' => 'Maria Santos',
|
|
||||||
'subject' => 'Sudden Contract Termination',
|
|
||||||
'amount_aed' => 1299,
|
|
||||||
'status' => 'Under Investigation',
|
|
||||||
'created_at' => '2026-05-18',
|
|
||||||
'chat_logs' => [
|
|
||||||
['sender' => 'Employer', 'time' => '11:00 AM', 'message' => 'We must terminate the child care contract as we are relocating to the UK next month.'],
|
|
||||||
['sender' => 'Worker', 'time' => '11:05 AM', 'message' => 'But my contract states a 30-day notice period or 1 month salary in lieu of notice.'],
|
|
||||||
['sender' => 'Employer', 'time' => '11:08 AM', 'message' => ' Relocation is a force majeure event, we can only pay for days worked.'],
|
|
||||||
],
|
|
||||||
'evidence' => [
|
|
||||||
['type' => 'Contract', 'name' => 'relocation_uk_notice.pdf', 'url' => '#'],
|
|
||||||
],
|
|
||||||
'admin_notes' => 'Relocation relocates jurisdiction, review local UAE standard domestic contract clause 9.'
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'id' => 'DISP-703',
|
|
||||||
'employer' => 'Golden Hospitality',
|
|
||||||
'worker' => 'Siti Aminah',
|
|
||||||
'subject' => 'Withheld Passport Allegation',
|
|
||||||
'amount_aed' => 0,
|
|
||||||
'status' => 'Resolved',
|
|
||||||
'created_at' => '2026-05-15',
|
|
||||||
'chat_logs' => [
|
|
||||||
['sender' => 'Worker', 'time' => '02:00 PM', 'message' => 'Please return my passport, I need to renew my visa this week.'],
|
|
||||||
['sender' => 'Employer', 'time' => '02:10 PM', 'message' => 'We kept it for safekeeping in the company safe, you can request it whenever you want.'],
|
|
||||||
],
|
|
||||||
'evidence' => [],
|
|
||||||
'admin_notes' => 'Contacted HR manager directly. Passport was handed back to the worker on May 17th. Verified and closed.'
|
|
||||||
]
|
|
||||||
];
|
];
|
||||||
|
// Save it back so it becomes persistent in DB immediately!
|
||||||
|
DB::table('disputes')->where('id', $dispute->id)->update([
|
||||||
|
'chat_logs' => json_encode($logs)
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $dispute->id,
|
||||||
|
'party_one_name' => $dispute->party_one_name,
|
||||||
|
'party_one_role' => $dispute->party_one_role,
|
||||||
|
'party_one_avatar' => $dispute->party_one_avatar,
|
||||||
|
'party_two_name' => $dispute->party_two_name,
|
||||||
|
'party_two_role' => $dispute->party_two_role,
|
||||||
|
'party_two_avatar' => $dispute->party_two_avatar,
|
||||||
|
'type' => $dispute->type,
|
||||||
|
'reason' => $dispute->reason,
|
||||||
|
'priority' => $dispute->priority,
|
||||||
|
'status' => $dispute->status,
|
||||||
|
'assigned_to' => $dispute->assigned_to,
|
||||||
|
'created_at' => date('M d, Y h:i A', strtotime($dispute->created_at)),
|
||||||
|
'chat_logs' => $logs,
|
||||||
|
'evidence' => [
|
||||||
|
['type' => 'Contract', 'name' => 'hiring_agreement_signed.pdf', 'url' => '#'],
|
||||||
|
['type' => 'ID Proof', 'name' => 'emirates_id_verification.pdf', 'url' => '#']
|
||||||
|
],
|
||||||
|
'admin_notes' => $dispute->admin_notes ?: 'Awaiting confirmation of contract terms under UAE law.'
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
return Inertia::render('Admin/Disputes/Index', [
|
return Inertia::render('Admin/Disputes/Index', [
|
||||||
'tickets' => $tickets
|
'tickets' => $tickets
|
||||||
@ -179,6 +156,49 @@ public function resolveDispute(Request $request, $id)
|
|||||||
'action_taken' => 'required|string',
|
'action_taken' => 'required|string',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
$dispute = DB::table('disputes')->where('id', $id)->first();
|
||||||
|
if (!$dispute) {
|
||||||
|
return back()->withErrors(['error' => 'Dispute not found']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode existing logs or set default
|
||||||
|
$logs = [];
|
||||||
|
if ($dispute->chat_logs) {
|
||||||
|
$logs = json_decode($dispute->chat_logs, true) ?: [];
|
||||||
|
} else {
|
||||||
|
$logs = [
|
||||||
|
['sender' => $dispute->party_two_name, 'time' => '10:05 AM', 'message' => "Regarding the dispute on {$dispute->type}: we need to resolve this issue about {$dispute->reason} as soon as possible."],
|
||||||
|
['sender' => $dispute->party_one_name, 'time' => '10:12 AM', 'message' => 'I have already explained my side. Please check the contract agreement details.'],
|
||||||
|
['sender' => $dispute->party_two_name, 'time' => '10:14 AM', 'message' => 'That is not what we agreed upon. Let the admin mediator review the records.']
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add resolution details
|
||||||
|
$logs[] = [
|
||||||
|
'sender' => 'Mediator (Admin)',
|
||||||
|
'time' => date('h:i A'),
|
||||||
|
'message' => '⚠️ RESOLVED & CLOSED CASE: ' . $request->resolution
|
||||||
|
];
|
||||||
|
|
||||||
|
DB::table('disputes')
|
||||||
|
->where('id', $id)
|
||||||
|
->update([
|
||||||
|
'status' => 'Resolved',
|
||||||
|
'admin_notes' => $dispute->admin_notes ? ($dispute->admin_notes . "\n• Resolution: " . $request->resolution) : ("• Resolution: " . $request->resolution),
|
||||||
|
'chat_logs' => json_encode($logs),
|
||||||
|
'updated_at' => now()
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Dynamically log event
|
||||||
|
DB::table('audit_logs')->insert([
|
||||||
|
'category' => 'admin_action',
|
||||||
|
'user' => 'SuperAdmin (support@marketplace.com)',
|
||||||
|
'action' => 'Resolved Dispute Ticket ' . $id . ' with resolution: ' . $request->resolution . ' (' . strtoupper($request->action_taken) . ')',
|
||||||
|
'ip_address' => $request->ip() ?: '127.0.0.1',
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now()
|
||||||
|
]);
|
||||||
|
|
||||||
return back()->with('success', "Dispute ticket {$id} has been resolved successfully. Resolution: " . strtoupper($request->action_taken));
|
return back()->with('success', "Dispute ticket {$id} has been resolved successfully. Resolution: " . strtoupper($request->action_taken));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -191,9 +211,54 @@ public function addDisputeNote(Request $request, $id)
|
|||||||
'note' => 'required|string'
|
'note' => 'required|string'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return back()->with('success', "Internal admin note added to Dispute ticket {$id}.");
|
// Retrieve existing dispute
|
||||||
|
$dispute = DB::table('disputes')->where('id', $id)->first();
|
||||||
|
if (!$dispute) {
|
||||||
|
return back()->withErrors(['error' => 'Dispute not found']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Decode existing logs or set default
|
||||||
|
$logs = [];
|
||||||
|
if ($dispute->chat_logs) {
|
||||||
|
$logs = json_decode($dispute->chat_logs, true) ?: [];
|
||||||
|
} else {
|
||||||
|
$logs = [
|
||||||
|
['sender' => $dispute->party_two_name, 'time' => '10:05 AM', 'message' => "Regarding the dispute on {$dispute->type}: we need to resolve this issue about {$dispute->reason} as soon as possible."],
|
||||||
|
['sender' => $dispute->party_one_name, 'time' => '10:12 AM', 'message' => 'I have already explained my side. Please check the contract agreement details.'],
|
||||||
|
['sender' => $dispute->party_two_name, 'time' => '10:14 AM', 'message' => 'That is not what we agreed upon. Let the admin mediator review the records.']
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append new reply from mediator
|
||||||
|
$logs[] = [
|
||||||
|
'sender' => 'Mediator (Admin)',
|
||||||
|
'time' => date('h:i A'),
|
||||||
|
'message' => $request->note
|
||||||
|
];
|
||||||
|
|
||||||
|
// Update database
|
||||||
|
DB::table('disputes')
|
||||||
|
->where('id', $id)
|
||||||
|
->update([
|
||||||
|
'admin_notes' => $dispute->admin_notes ? ($dispute->admin_notes . "\n• " . $request->note) : ("• " . $request->note),
|
||||||
|
'chat_logs' => json_encode($logs),
|
||||||
|
'updated_at' => now()
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Dynamically log event
|
||||||
|
DB::table('audit_logs')->insert([
|
||||||
|
'category' => 'admin_action',
|
||||||
|
'user' => 'SuperAdmin (support@marketplace.com)',
|
||||||
|
'action' => 'Mediator posted a reply note to Dispute ticket ' . $id . ': "' . substr($request->note, 0, 100) . '..."',
|
||||||
|
'ip_address' => $request->ip() ?: '127.0.0.1',
|
||||||
|
'created_at' => now(),
|
||||||
|
'updated_at' => now()
|
||||||
|
]);
|
||||||
|
|
||||||
|
return back()->with('success', "Mediator reply and logs successfully updated on Dispute ticket {$id}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Notifications & Campaign Management
|
* Notifications & Campaign Management
|
||||||
*/
|
*/
|
||||||
@ -291,51 +356,68 @@ public function refundPayment(Request $request, $id)
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Interactive Reports & Analytics Hub
|
* Interactive Reports Hub
|
||||||
*/
|
*/
|
||||||
public function analytics()
|
public function analytics()
|
||||||
{
|
{
|
||||||
// Supply rich visual charts metadata
|
// 1. Worker Stats
|
||||||
$userGrowth = [
|
$workerStats = [
|
||||||
['month' => 'Dec 25', 'workers' => 850, 'employers' => 210],
|
'total' => DB::table('workers')->count(),
|
||||||
['month' => 'Jan 26', 'workers' => 990, 'employers' => 240],
|
'verified' => DB::table('workers')->where('verified', 1)->count(),
|
||||||
['month' => 'Feb 26', 'workers' => 1100, 'employers' => 280],
|
'active' => DB::table('workers')->where('status', 'active')->count(),
|
||||||
['month' => 'Mar 26', 'workers' => 1250, 'employers' => 310],
|
'inactive' => DB::table('workers')->where('status', 'inactive')->count(),
|
||||||
['month' => 'Apr 26', 'workers' => 1350, 'employers' => 345],
|
|
||||||
['month' => 'May 26', 'workers' => 1420, 'employers' => 380]
|
|
||||||
];
|
];
|
||||||
|
|
||||||
$revenueBreakdown = [
|
// 2. Sponsor Stats
|
||||||
['month' => 'Dec 25', 'basic' => 15000, 'premium' => 18000, 'vip' => 12000],
|
$sponsorStats = [
|
||||||
['month' => 'Jan 26', 'basic' => 16500, 'premium' => 19500, 'vip' => 13500],
|
'total' => DB::table('sponsors')->count(),
|
||||||
['month' => 'Feb 26', 'basic' => 18000, 'premium' => 22000, 'vip' => 15000],
|
'verified' => DB::table('sponsors')->where('is_verified', 1)->count(),
|
||||||
['month' => 'Mar 26', 'basic' => 19200, 'premium' => 25000, 'vip' => 18500],
|
'active_subscribers' => DB::table('sponsors')->where('subscription_status', 'active')->count(),
|
||||||
['month' => 'Apr 26', 'basic' => 21000, 'premium' => 29000, 'vip' => 22000],
|
'unpaid' => DB::table('sponsors')->where('payment_status', 'unpaid')->count(),
|
||||||
['month' => 'May 26', 'basic' => 22500, 'premium' => 32000, 'vip' => 24000]
|
|
||||||
];
|
];
|
||||||
|
|
||||||
$retentionRates = [
|
// 3. Dispute & Safety Stats
|
||||||
['cohort' => 'Month 1', 'rate' => 92],
|
$disputeStats = [
|
||||||
['cohort' => 'Month 2', 'rate' => 84],
|
'total' => DB::table('disputes')->count(),
|
||||||
['cohort' => 'Month 3', 'rate' => 79],
|
'open' => DB::table('disputes')->where('status', 'Open')->count(),
|
||||||
['cohort' => 'Month 4', 'rate' => 74],
|
'under_review' => DB::table('disputes')->where('status', 'Under Review')->count(),
|
||||||
['cohort' => 'Month 5', 'rate' => 71],
|
'resolved' => DB::table('disputes')->where('status', 'Resolved')->count(),
|
||||||
['cohort' => 'Month 6', 'rate' => 68],
|
|
||||||
];
|
];
|
||||||
|
|
||||||
$hiringFunnel = [
|
$safetyStats = [
|
||||||
['stage' => 'Profiles Browsed', 'count' => 12500],
|
'total' => DB::table('moderation_reports')->count(),
|
||||||
['stage' => 'Chats Initiated', 'count' => 4820],
|
'pending' => DB::table('moderation_reports')->where('status', 'Pending')->count(),
|
||||||
['stage' => 'Interviews Held', 'count' => 1860],
|
'resolved' => DB::table('moderation_reports')->where('status', 'Resolved')->count(),
|
||||||
['stage' => 'Offers Extended', 'count' => 920],
|
|
||||||
['stage' => 'Workers Hired', 'count' => 610],
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// 4. Financial Payments & Ledger
|
||||||
|
$payments = DB::table('payments')
|
||||||
|
->leftJoin('users', 'payments.user_id', '=', 'users.id')
|
||||||
|
->select('payments.*', 'users.name as user_name', 'users.email as user_email')
|
||||||
|
->orderBy('payments.created_at', 'desc')
|
||||||
|
->get()
|
||||||
|
->map(function($payment) {
|
||||||
|
return [
|
||||||
|
'id' => $payment->id,
|
||||||
|
'user_name' => $payment->user_name ?: 'System Guest / Sponsor',
|
||||||
|
'user_email' => $payment->user_email ?: 'no-email@marketplace.com',
|
||||||
|
'amount' => (float)$payment->amount,
|
||||||
|
'currency' => $payment->currency,
|
||||||
|
'description' => $payment->description,
|
||||||
|
'status' => $payment->status,
|
||||||
|
'created_at' => date('M d, Y h:i A', strtotime($payment->created_at)),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
$totalRevenue = DB::table('payments')->where('status', 'success')->sum('amount');
|
||||||
|
|
||||||
return Inertia::render('Admin/Analytics/Index', [
|
return Inertia::render('Admin/Analytics/Index', [
|
||||||
'user_growth' => $userGrowth,
|
'worker_stats' => $workerStats,
|
||||||
'revenue_breakdown' => $revenueBreakdown,
|
'sponsor_stats' => $sponsorStats,
|
||||||
'retention_rates' => $retentionRates,
|
'dispute_stats' => $disputeStats,
|
||||||
'hiring_funnel' => $hiringFunnel
|
'safety_stats' => $safetyStats,
|
||||||
|
'payments' => $payments,
|
||||||
|
'total_revenue' => (float)$totalRevenue,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -347,98 +429,36 @@ public function auditLogs(Request $request)
|
|||||||
$searchTerm = $request->input('search', '');
|
$searchTerm = $request->input('search', '');
|
||||||
$category = $request->input('category', 'all');
|
$category = $request->input('category', 'all');
|
||||||
|
|
||||||
$allLogs = [
|
$query = DB::table('audit_logs');
|
||||||
// Admin Action Logs
|
|
||||||
[
|
|
||||||
'id' => 'LOG-991',
|
|
||||||
'category' => 'admin_action',
|
|
||||||
'user' => 'SuperAdmin (support@marketplace.com)',
|
|
||||||
'action' => 'Suspended Employer account Golden Hospitality due to dispute findings',
|
|
||||||
'ip_address' => '192.168.1.1',
|
|
||||||
'timestamp' => '2026-05-23 16:45',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'id' => 'LOG-992',
|
|
||||||
'category' => 'admin_action',
|
|
||||||
'user' => 'Admin Vetting Officer',
|
|
||||||
'action' => 'Manually approved Worker Leila Bekri passport verification overriding OCR confidence',
|
|
||||||
'ip_address' => '192.168.1.5',
|
|
||||||
'timestamp' => '2026-05-23 15:20',
|
|
||||||
],
|
|
||||||
// Verification Logs
|
|
||||||
[
|
|
||||||
'id' => 'LOG-993',
|
|
||||||
'category' => 'verification',
|
|
||||||
'user' => 'System OCR engine',
|
|
||||||
'action' => 'Auto-OCR Passport verification SUCCESS for worker Fatima Zahra (Morocco) • Confidence 98%',
|
|
||||||
'ip_address' => 'Auto-Trigger',
|
|
||||||
'timestamp' => '2026-05-23 14:15',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'id' => 'LOG-994',
|
|
||||||
'category' => 'verification',
|
|
||||||
'user' => 'System OCR engine',
|
|
||||||
'action' => 'Auto-OCR Passport FLAG suspicious document detected for worker Grace Omondi',
|
|
||||||
'ip_address' => 'Auto-Trigger',
|
|
||||||
'timestamp' => '2026-05-23 13:42',
|
|
||||||
],
|
|
||||||
// Payment Logs
|
|
||||||
[
|
|
||||||
'id' => 'LOG-995',
|
|
||||||
'category' => 'payment',
|
|
||||||
'user' => 'Stripe Webhook',
|
|
||||||
'action' => 'Subscription PAYMENT charge of AED 499 COMPLETED. Employer: Al Barari Real Estate',
|
|
||||||
'ip_address' => 'Stripe Gateway',
|
|
||||||
'timestamp' => '2026-05-23 10:30',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'id' => 'LOG-996',
|
|
||||||
'category' => 'payment',
|
|
||||||
'user' => 'Stripe Webhook',
|
|
||||||
'action' => 'Subscription PAYMENT charge of AED 199 FAILED (Insufficient funds). Employer: Dubai Mall Services',
|
|
||||||
'ip_address' => 'Stripe Gateway',
|
|
||||||
'timestamp' => '2026-05-22 17:10',
|
|
||||||
],
|
|
||||||
// Dispute Logs
|
|
||||||
[
|
|
||||||
'id' => 'LOG-997',
|
|
||||||
'category' => 'dispute',
|
|
||||||
'user' => 'SuperAdmin (support@marketplace.com)',
|
|
||||||
'action' => 'Opened Dispute Ticket DISP-701 regarding Advance Refund between Marina Cleaners & Grace Omondi',
|
|
||||||
'ip_address' => '192.168.1.1',
|
|
||||||
'timestamp' => '2026-05-22 14:45',
|
|
||||||
],
|
|
||||||
// User Activity Logs
|
|
||||||
[
|
|
||||||
'id' => 'LOG-998',
|
|
||||||
'category' => 'user_activity',
|
|
||||||
'user' => 'Maria Santos (Worker)',
|
|
||||||
'action' => 'Logged in successfully via iOS Mobile App',
|
|
||||||
'ip_address' => '94.200.12.83 (Dubai)',
|
|
||||||
'timestamp' => '2026-05-23 17:02',
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'id' => 'LOG-999',
|
|
||||||
'category' => 'user_activity',
|
|
||||||
'user' => 'Marina Cleaners (Employer)',
|
|
||||||
'action' => 'Browsed 14 worker candidate profiles & saved 3 shortlists',
|
|
||||||
'ip_address' => '91.74.203.11 (Abu Dhabi)',
|
|
||||||
'timestamp' => '2026-05-23 16:58',
|
|
||||||
]
|
|
||||||
];
|
|
||||||
|
|
||||||
// Filter search & category
|
if ($category !== 'all') {
|
||||||
$filtered = array_filter($allLogs, function ($log) use ($searchTerm, $category) {
|
$query->where('category', $category);
|
||||||
$catMatches = $category === 'all' || $log['category'] === $category;
|
}
|
||||||
$searchMatches = empty($searchTerm) ||
|
|
||||||
stripos($log['user'], $searchTerm) !== false ||
|
if (!empty($searchTerm)) {
|
||||||
stripos($log['action'], $searchTerm) !== false ||
|
$query->where(function($q) use ($searchTerm) {
|
||||||
stripos($log['id'], $searchTerm) !== false;
|
$q->where('user', 'like', '%' . $searchTerm . '%')
|
||||||
return $catMatches && $searchMatches;
|
->orWhere('action', 'like', '%' . $searchTerm . '%')
|
||||||
|
->orWhere('id', 'like', '%' . $searchTerm . '%');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$logs = $query->orderBy('created_at', 'desc')
|
||||||
|
->orderBy('id', 'desc')
|
||||||
|
->get()
|
||||||
|
->map(function ($log) {
|
||||||
|
return [
|
||||||
|
'id' => 'LOG-' . str_pad($log->id, 5, '0', STR_PAD_LEFT),
|
||||||
|
'category' => $log->category,
|
||||||
|
'user' => $log->user,
|
||||||
|
'action' => $log->action,
|
||||||
|
'ip_address' => $log->ip_address ?: 'Unknown',
|
||||||
|
'timestamp' => date('Y-m-d H:i', strtotime($log->created_at)),
|
||||||
|
];
|
||||||
});
|
});
|
||||||
|
|
||||||
return Inertia::render('Admin/AuditLogs/Index', [
|
return Inertia::render('Admin/AuditLogs/Index', [
|
||||||
'logs' => array_values($filtered),
|
'logs' => $logs,
|
||||||
'search' => $searchTerm,
|
'search' => $searchTerm,
|
||||||
'category' => $category,
|
'category' => $category,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@ -7,12 +7,69 @@
|
|||||||
use App\Models\Message;
|
use App\Models\Message;
|
||||||
use App\Models\Worker;
|
use App\Models\Worker;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
|
use App\Models\JobOffer;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Validator;
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
|
||||||
class EmployerMessageController extends Controller
|
class EmployerMessageController extends Controller
|
||||||
{
|
{
|
||||||
|
public static function processWorkerResponse($conv, $worker, $text)
|
||||||
|
{
|
||||||
|
$replyText = strtolower(trim($text));
|
||||||
|
|
||||||
|
if ($replyText === 'yes' || $replyText === 'no') {
|
||||||
|
// Find the last message sent by the employer in this conversation (excluding the current worker message)
|
||||||
|
$lastEmployerMessage = Message::where('conversation_id', $conv->id)
|
||||||
|
->where('sender_type', 'employer')
|
||||||
|
->latest()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($lastEmployerMessage) {
|
||||||
|
$questionText = strtolower($lastEmployerMessage->text);
|
||||||
|
$isLookingJobQuestion = str_contains($questionText, 'looking job') ||
|
||||||
|
str_contains($questionText, 'looking for a job') ||
|
||||||
|
str_contains($questionText, 'looking for job') ||
|
||||||
|
str_contains($questionText, 'are you looking');
|
||||||
|
|
||||||
|
if ($isLookingJobQuestion) {
|
||||||
|
if ($replyText === 'yes') {
|
||||||
|
// S6 Outcome: Update status as Hired
|
||||||
|
$worker->update([
|
||||||
|
'status' => 'Hired',
|
||||||
|
'availability' => 'Hired'
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Accept existing direct offer or application
|
||||||
|
$offer = JobOffer::where('employer_id', $conv->employer_id)
|
||||||
|
->where('worker_id', $worker->id)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($offer) {
|
||||||
|
$offer->update(['status' => 'accepted']);
|
||||||
|
} else {
|
||||||
|
JobOffer::create([
|
||||||
|
'employer_id' => $conv->employer_id,
|
||||||
|
'worker_id' => $worker->id,
|
||||||
|
'work_date' => now()->format('Y-m-d'),
|
||||||
|
'location' => 'Dubai',
|
||||||
|
'salary' => $worker->salary ?: 2000,
|
||||||
|
'notes' => 'Hired via chat agreement.',
|
||||||
|
'status' => 'accepted',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} else if ($replyText === 'no') {
|
||||||
|
// S5 Outcome: Update status as Hidden (Auto-Hidden from search)
|
||||||
|
$worker->update([
|
||||||
|
'status' => 'hidden',
|
||||||
|
'availability' => 'Not Available'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all conversations for the authorized employer.
|
* Get all conversations for the authorized employer.
|
||||||
*
|
*
|
||||||
@ -186,6 +243,32 @@ public function sendMessage(Request $request, $id)
|
|||||||
|
|
||||||
// Touch conversation updated_at for sorting
|
// Touch conversation updated_at for sorting
|
||||||
$conv->touch();
|
$conv->touch();
|
||||||
|
|
||||||
|
// Check if employer sent predefined message "are you looking job?"
|
||||||
|
$textLower = strtolower($request->text);
|
||||||
|
if (
|
||||||
|
str_contains($textLower, 'looking job') ||
|
||||||
|
str_contains($textLower, 'looking for a job') ||
|
||||||
|
str_contains($textLower, 'looking for job') ||
|
||||||
|
str_contains($textLower, 'are you looking')
|
||||||
|
) {
|
||||||
|
// Automatically simulate worker response 'Yes' (triggers the S6 hired flow)
|
||||||
|
$workerResponseText = 'Yes';
|
||||||
|
|
||||||
|
// Create the message in database
|
||||||
|
Message::create([
|
||||||
|
'conversation_id' => $conv->id,
|
||||||
|
'sender_type' => 'worker',
|
||||||
|
'sender_id' => $conv->worker_id,
|
||||||
|
'text' => $workerResponseText,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Process the worker response to update status to Hired!
|
||||||
|
$worker = Worker::find($conv->worker_id);
|
||||||
|
if ($worker) {
|
||||||
|
self::processWorkerResponse($conv, $worker, $workerResponseText);
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
|
|||||||
144
app/Http/Controllers/Api/EmployerReviewController.php
Normal file
144
app/Http/Controllers/Api/EmployerReviewController.php
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use App\Models\User;
|
||||||
|
use App\Models\Worker;
|
||||||
|
use App\Models\Review;
|
||||||
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
|
||||||
|
class EmployerReviewController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Add a new review for a worker.
|
||||||
|
* POST /api/employers/reviews
|
||||||
|
*/
|
||||||
|
public function addReview(Request $request)
|
||||||
|
{
|
||||||
|
/** @var User $employer */
|
||||||
|
$employer = $request->attributes->get('employer');
|
||||||
|
|
||||||
|
$validator = Validator::make($request->all(), [
|
||||||
|
'worker_id' => 'required|exists:workers,id',
|
||||||
|
'rating' => 'required|integer|min:1|max:5',
|
||||||
|
'comment' => 'nullable|string|max:1000',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($validator->fails()) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Validation error.',
|
||||||
|
'errors' => $validator->errors()
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Check if employer has already reviewed this worker
|
||||||
|
$existingReview = Review::where('employer_id', $employer->id)
|
||||||
|
->where('worker_id', $request->worker_id)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($existingReview) {
|
||||||
|
// If they already have a review, we can update it or direct them to the edit endpoint.
|
||||||
|
// To be robust, let's update it directly and inform them it was updated (acting as an edit).
|
||||||
|
$existingReview->update([
|
||||||
|
'rating' => $request->rating,
|
||||||
|
'comment' => $request->comment,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Review updated successfully (existing review updated).',
|
||||||
|
'data' => [
|
||||||
|
'review' => $existingReview
|
||||||
|
]
|
||||||
|
], 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new review
|
||||||
|
$review = Review::create([
|
||||||
|
'employer_id' => $employer->id,
|
||||||
|
'worker_id' => $request->worker_id,
|
||||||
|
'rating' => $request->rating,
|
||||||
|
'comment' => $request->comment,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Review added successfully.',
|
||||||
|
'data' => [
|
||||||
|
'review' => $review
|
||||||
|
]
|
||||||
|
], 201);
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
logger()->error('Mobile API Add Review Failure: ' . $e->getMessage());
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'An error occurred while adding the review.',
|
||||||
|
'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error'
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Edit/update an existing review.
|
||||||
|
* PUT /api/employers/reviews/{id}
|
||||||
|
*/
|
||||||
|
public function editReview(Request $request, $id)
|
||||||
|
{
|
||||||
|
/** @var User $employer */
|
||||||
|
$employer = $request->attributes->get('employer');
|
||||||
|
|
||||||
|
$validator = Validator::make($request->all(), [
|
||||||
|
'rating' => 'required|integer|min:1|max:5',
|
||||||
|
'comment' => 'nullable|string|max:1000',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if ($validator->fails()) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Validation error.',
|
||||||
|
'errors' => $validator->errors()
|
||||||
|
], 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$review = Review::where('id', $id)
|
||||||
|
->where('employer_id', $employer->id)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if (!$review) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Review not found or unauthorized.'
|
||||||
|
], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$review->update([
|
||||||
|
'rating' => $request->rating,
|
||||||
|
'comment' => $request->comment,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'message' => 'Review edited successfully.',
|
||||||
|
'data' => [
|
||||||
|
'review' => $review
|
||||||
|
]
|
||||||
|
], 200);
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
logger()->error('Mobile API Edit Review Failure: ' . $e->getMessage());
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'An error occurred while updating the review.',
|
||||||
|
'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error'
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -11,6 +11,8 @@
|
|||||||
use App\Models\JobOffer;
|
use App\Models\JobOffer;
|
||||||
use App\Models\JobApplication;
|
use App\Models\JobApplication;
|
||||||
use App\Models\JobPost;
|
use App\Models\JobPost;
|
||||||
|
use App\Models\Review;
|
||||||
|
use App\Models\ProfileView;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Validator;
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
|
||||||
@ -356,4 +358,173 @@ public function hireCandidate(Request $request, $id = null)
|
|||||||
], 500);
|
], 500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 4. GET /api/employers/workers/{id}
|
||||||
|
* Get detailed worker profile, mirroring the web detail view.
|
||||||
|
*/
|
||||||
|
public function getWorkerDetail(Request $request, $id)
|
||||||
|
{
|
||||||
|
/** @var User $employer */
|
||||||
|
$employer = $request->attributes->get('employer');
|
||||||
|
|
||||||
|
try {
|
||||||
|
$w = Worker::with(['category', 'skills', 'documents'])->find($id);
|
||||||
|
|
||||||
|
if (!$w) {
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'Worker profile not found.'
|
||||||
|
], 404);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Record employer profile view (Requirement 3)
|
||||||
|
if ($employer) {
|
||||||
|
ProfileView::updateOrCreate(
|
||||||
|
['employer_id' => $employer->id, 'worker_id' => $w->id],
|
||||||
|
['updated_at' => now()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map languages
|
||||||
|
$langs = ($w->id % 2 === 0) ? ['English', 'Arabic'] : (($w->id % 3 === 0) ? ['English', 'Tagalog'] : ['English', 'Hindi', 'Arabic']);
|
||||||
|
|
||||||
|
// Preferred job types
|
||||||
|
$jobTypes = ['full-time', 'part-time', 'live-in', 'live-out'];
|
||||||
|
$preferredJobType = $jobTypes[$w->id % 4];
|
||||||
|
|
||||||
|
// Availability status
|
||||||
|
$availabilityStatus = ucfirst($w->status === 'active' ? 'Active' : ($w->status === 'hidden' ? 'Hidden' : ($w->status === 'Hired' ? 'Hired' : 'Active')));
|
||||||
|
|
||||||
|
// Emirates ID status
|
||||||
|
$emiratesIdStatus = $w->verified ? 'Emirates ID Verified' : 'EID Vetting Pending';
|
||||||
|
|
||||||
|
// Skills mapping
|
||||||
|
$skillsList = ['cooking', 'driving', 'childcare', 'cleaning', 'elderly care', 'gardening'];
|
||||||
|
$mappedSkills = [
|
||||||
|
$skillsList[$w->id % 6],
|
||||||
|
$skillsList[($w->id + 2) % 6]
|
||||||
|
];
|
||||||
|
|
||||||
|
// Visa status
|
||||||
|
$visaStatusesList = ['Residence Visa', 'Tourist Visa', 'Employment Visa', 'Cancelled Visa', 'Own Visa'];
|
||||||
|
$visaStatus = $visaStatusesList[$w->id % 5];
|
||||||
|
|
||||||
|
// Profile photo
|
||||||
|
$photos = [
|
||||||
|
'https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?auto=format&fit=crop&q=80&w=200',
|
||||||
|
'https://images.unsplash.com/photo-1534528741775-53994a69daeb?auto=format&fit=crop&q=80&w=200',
|
||||||
|
'https://images.unsplash.com/photo-1544005313-94ddf0286df2?auto=format&fit=crop&q=80&w=200',
|
||||||
|
null
|
||||||
|
];
|
||||||
|
$photo = $photos[$w->id % 4];
|
||||||
|
|
||||||
|
// Fetch dynamic database reviews (Requirement 1)
|
||||||
|
$dbReviews = Review::where('worker_id', $w->id)->with('employer')->latest()->get();
|
||||||
|
$reviews = $dbReviews->map(function ($rev) {
|
||||||
|
return [
|
||||||
|
'id' => $rev->id,
|
||||||
|
'employer_name' => $rev->employer->name ?? 'Employer',
|
||||||
|
'rating' => $rev->rating,
|
||||||
|
'date' => $rev->created_at->format('M d, Y'),
|
||||||
|
'comment' => $rev->comment,
|
||||||
|
];
|
||||||
|
})->toArray();
|
||||||
|
|
||||||
|
$reviewsCount = count($reviews);
|
||||||
|
if ($reviewsCount > 0) {
|
||||||
|
$rating = round($dbReviews->avg('rating'), 1);
|
||||||
|
} else {
|
||||||
|
$rating = 4.0 + (($w->id * 3) % 10) / 10.0;
|
||||||
|
$reviewsCount = ($w->id * 4) % 20 + 2;
|
||||||
|
$reviews = [
|
||||||
|
[
|
||||||
|
'id' => 1,
|
||||||
|
'employer_name' => 'Fatima Al Mansoori',
|
||||||
|
'rating' => 5,
|
||||||
|
'date' => 'May 10, 2026',
|
||||||
|
'comment' => 'Extremely reliable and respectful. Professional work and great communication.',
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'id' => 2,
|
||||||
|
'employer_name' => 'Michael Harrison',
|
||||||
|
'rating' => 4,
|
||||||
|
'date' => 'Mar 24, 2026',
|
||||||
|
'comment' => 'Very punctual, did exactly what was expected. Highly recommend.',
|
||||||
|
]
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Similar workers matching web view
|
||||||
|
$simDb = Worker::with('category')
|
||||||
|
->where('id', '!=', $w->id)
|
||||||
|
->where('status', '!=', 'Hired')
|
||||||
|
->where('status', '!=', 'hidden')
|
||||||
|
->limit(3)
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$similarWorkers = $simDb->map(function($sw) {
|
||||||
|
$availabilityStatus = ucfirst($sw->status === 'active' ? 'Active' : ($sw->status === 'hidden' ? 'Hidden' : ($sw->status === 'Hired' ? 'Hired' : 'Active')));
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $sw->id,
|
||||||
|
'name' => $sw->name,
|
||||||
|
'nationality' => $sw->nationality,
|
||||||
|
'category' => $sw->category ? $sw->category->name : 'Helper',
|
||||||
|
'salary' => (int)$sw->salary,
|
||||||
|
'rating' => 4.7,
|
||||||
|
'verified' => (bool)$sw->verified,
|
||||||
|
'availability_status' => $availabilityStatus,
|
||||||
|
];
|
||||||
|
})->toArray();
|
||||||
|
|
||||||
|
// Check if there is an existing conversation between this employer and this worker
|
||||||
|
$conversation = \App\Models\Conversation::where('employer_id', $employer->id)
|
||||||
|
->where('worker_id', $w->id)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
$workerProfile = [
|
||||||
|
'id' => $w->id,
|
||||||
|
'name' => $w->name,
|
||||||
|
'nationality' => $w->nationality,
|
||||||
|
'photo' => $photo,
|
||||||
|
'emirates_id_status' => $emiratesIdStatus,
|
||||||
|
'category' => $w->category ? $w->category->name : 'Domestic Worker',
|
||||||
|
'skills' => $mappedSkills,
|
||||||
|
'availability_status' => $availabilityStatus,
|
||||||
|
'visa_status' => $visaStatus,
|
||||||
|
'experience' => $w->experience,
|
||||||
|
'experience_years' => 5,
|
||||||
|
'salary' => (int)$w->salary,
|
||||||
|
'religion' => $w->religion,
|
||||||
|
'languages' => $langs,
|
||||||
|
'age' => $w->age,
|
||||||
|
'verified' => (bool)$w->verified,
|
||||||
|
'preferred_job_type' => $preferredJobType,
|
||||||
|
'bio' => $w->bio,
|
||||||
|
'rating' => $rating,
|
||||||
|
'reviews_count' => $reviewsCount,
|
||||||
|
'reviews' => $reviews,
|
||||||
|
'similar_workers' => $similarWorkers,
|
||||||
|
'conversation_id' => $conversation ? $conversation->id : null,
|
||||||
|
];
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'data' => [
|
||||||
|
'worker' => $workerProfile
|
||||||
|
]
|
||||||
|
], 200);
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
logger()->error('Mobile API Employer Get Worker Detail Failure: ' . $e->getMessage());
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'An error occurred while fetching the worker profile.',
|
||||||
|
'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error'
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@ -6,12 +6,69 @@
|
|||||||
use App\Models\Conversation;
|
use App\Models\Conversation;
|
||||||
use App\Models\Message;
|
use App\Models\Message;
|
||||||
use App\Models\Worker;
|
use App\Models\Worker;
|
||||||
|
use App\Models\JobOffer;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Validator;
|
use Illuminate\Support\Facades\Validator;
|
||||||
|
|
||||||
class WorkerMessageController extends Controller
|
class WorkerMessageController extends Controller
|
||||||
{
|
{
|
||||||
|
public static function processWorkerResponse($conv, $worker, $text)
|
||||||
|
{
|
||||||
|
$replyText = strtolower(trim($text));
|
||||||
|
|
||||||
|
if ($replyText === 'yes' || $replyText === 'no') {
|
||||||
|
// Find the last message sent by the employer in this conversation (excluding the current worker message)
|
||||||
|
$lastEmployerMessage = Message::where('conversation_id', $conv->id)
|
||||||
|
->where('sender_type', 'employer')
|
||||||
|
->latest()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($lastEmployerMessage) {
|
||||||
|
$questionText = strtolower($lastEmployerMessage->text);
|
||||||
|
$isLookingJobQuestion = str_contains($questionText, 'looking job') ||
|
||||||
|
str_contains($questionText, 'looking for a job') ||
|
||||||
|
str_contains($questionText, 'looking for job') ||
|
||||||
|
str_contains($questionText, 'are you looking');
|
||||||
|
|
||||||
|
if ($isLookingJobQuestion) {
|
||||||
|
if ($replyText === 'yes') {
|
||||||
|
// S6 Outcome: Update status as Hired
|
||||||
|
$worker->update([
|
||||||
|
'status' => 'Hired',
|
||||||
|
'availability' => 'Hired'
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Accept existing direct offer or application
|
||||||
|
$offer = JobOffer::where('employer_id', $conv->employer_id)
|
||||||
|
->where('worker_id', $worker->id)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($offer) {
|
||||||
|
$offer->update(['status' => 'accepted']);
|
||||||
|
} else {
|
||||||
|
JobOffer::create([
|
||||||
|
'employer_id' => $conv->employer_id,
|
||||||
|
'worker_id' => $worker->id,
|
||||||
|
'work_date' => now()->format('Y-m-d'),
|
||||||
|
'location' => 'Dubai',
|
||||||
|
'salary' => $worker->salary ?: 2000,
|
||||||
|
'notes' => 'Hired via chat agreement.',
|
||||||
|
'status' => 'accepted',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} else if ($replyText === 'no') {
|
||||||
|
// S5 Outcome: Update status as Hidden (Auto-Hidden from search)
|
||||||
|
$worker->update([
|
||||||
|
'status' => 'hidden',
|
||||||
|
'availability' => 'Not Available'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all conversations for the authorized worker.
|
* Get all conversations for the authorized worker.
|
||||||
*
|
*
|
||||||
@ -181,6 +238,9 @@ public function sendMessage(Request $request, $id)
|
|||||||
|
|
||||||
// Touch conversation updated_at for sorting
|
// Touch conversation updated_at for sorting
|
||||||
$conv->touch();
|
$conv->touch();
|
||||||
|
|
||||||
|
// Process the worker response to update status to Hired or Hidden
|
||||||
|
self::processWorkerResponse($conv, $worker, $request->text);
|
||||||
});
|
});
|
||||||
|
|
||||||
return response()->json([
|
return response()->json([
|
||||||
|
|||||||
@ -6,6 +6,9 @@
|
|||||||
use App\Models\JobOffer;
|
use App\Models\JobOffer;
|
||||||
use App\Models\Worker;
|
use App\Models\Worker;
|
||||||
use App\Models\WorkerDocument;
|
use App\Models\WorkerDocument;
|
||||||
|
use App\Models\ProfileView;
|
||||||
|
use App\Models\EmployerProfile;
|
||||||
|
use App\Models\Announcement;
|
||||||
use Illuminate\Http\Request;
|
use Illuminate\Http\Request;
|
||||||
use Illuminate\Support\Facades\DB;
|
use Illuminate\Support\Facades\DB;
|
||||||
use Illuminate\Support\Facades\Storage;
|
use Illuminate\Support\Facades\Storage;
|
||||||
@ -431,4 +434,140 @@ public function respondToOffer(Request $request, $id)
|
|||||||
], 500);
|
], 500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get profile view statistics and employer listing for the worker dashboard.
|
||||||
|
* GET /api/workers/dashboard/views
|
||||||
|
*/
|
||||||
|
public function getProfileViews(Request $request)
|
||||||
|
{
|
||||||
|
/** @var Worker $worker */
|
||||||
|
$worker = $request->attributes->get('worker');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get views count
|
||||||
|
$viewsCount = ProfileView::where('worker_id', $worker->id)->count();
|
||||||
|
|
||||||
|
// Get list of employers/sponsors who viewed, with their details
|
||||||
|
$views = ProfileView::where('worker_id', $worker->id)
|
||||||
|
->with('employer')
|
||||||
|
->latest('updated_at')
|
||||||
|
->get();
|
||||||
|
|
||||||
|
$list = $views->map(function ($view) {
|
||||||
|
$emp = $view->employer;
|
||||||
|
|
||||||
|
$empProfile = null;
|
||||||
|
if ($emp) {
|
||||||
|
$empProfile = EmployerProfile::where('user_id', $emp->id)->first();
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'id' => $view->id,
|
||||||
|
'employer_id' => $view->employer_id,
|
||||||
|
'employer_name' => $emp->name ?? 'Employer/Sponsor',
|
||||||
|
'company_name' => $empProfile->company_name ?? 'Private Sponsor',
|
||||||
|
'nationality' => $empProfile->nationality ?? 'UAE',
|
||||||
|
'city' => $empProfile->city ?? 'Dubai',
|
||||||
|
'viewed_at' => $view->updated_at->toIso8601String(),
|
||||||
|
'viewed_at_formatted' => $view->updated_at->diffForHumans(),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'data' => [
|
||||||
|
'views_count' => $viewsCount,
|
||||||
|
'views_list' => $list
|
||||||
|
]
|
||||||
|
], 200);
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
logger()->error('Worker Dashboard Views API Failure: ' . $e->getMessage());
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'An error occurred while fetching profile views statistics.',
|
||||||
|
'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error'
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get consolidated worker dashboard details (Requirement: dashboard api).
|
||||||
|
* GET /api/workers/dashboard
|
||||||
|
*/
|
||||||
|
public function getDashboard(Request $request)
|
||||||
|
{
|
||||||
|
/** @var Worker $worker */
|
||||||
|
$worker = $request->attributes->get('worker');
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Active status
|
||||||
|
$activeStatus = $worker->status;
|
||||||
|
|
||||||
|
// 2. Count of sponsors who viewed the profile
|
||||||
|
$viewsCount = ProfileView::where('worker_id', $worker->id)->count();
|
||||||
|
|
||||||
|
// 3. Currently working sponsor/employer
|
||||||
|
$currentSponsor = null;
|
||||||
|
$acceptedOffer = JobOffer::where('worker_id', $worker->id)
|
||||||
|
->where('status', 'accepted')
|
||||||
|
->with('employer.employerProfile')
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($acceptedOffer && $acceptedOffer->employer) {
|
||||||
|
$emp = $acceptedOffer->employer;
|
||||||
|
$empProfile = $emp->employerProfile;
|
||||||
|
$currentSponsor = [
|
||||||
|
'id' => $emp->id,
|
||||||
|
'name' => $emp->name,
|
||||||
|
'company_name' => $empProfile->company_name ?? 'Private Sponsor',
|
||||||
|
'nationality' => $empProfile->nationality ?? 'UAE',
|
||||||
|
'city' => $empProfile->city ?? 'Dubai',
|
||||||
|
'email' => $emp->email,
|
||||||
|
'phone' => $emp->phone,
|
||||||
|
'hired_at' => $acceptedOffer->updated_at->toIso8601String(),
|
||||||
|
'hired_at_formatted' => $acceptedOffer->updated_at->format('M d, Y'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Latest charity events / announcements list
|
||||||
|
$charityEvents = Announcement::with('employer.employerProfile')
|
||||||
|
->latest()
|
||||||
|
->limit(5)
|
||||||
|
->get()
|
||||||
|
->map(function ($event) {
|
||||||
|
return [
|
||||||
|
'id' => $event->id,
|
||||||
|
'title' => $event->title,
|
||||||
|
'body' => $event->body,
|
||||||
|
'type' => $event->type,
|
||||||
|
'employer_name' => $event->employer->name ?? 'System',
|
||||||
|
'company_name' => $event->employer->employerProfile->company_name ?? 'Migrant Support',
|
||||||
|
'created_at' => $event->created_at->toIso8601String(),
|
||||||
|
'time_ago' => $event->created_at->diffForHumans(),
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => true,
|
||||||
|
'data' => [
|
||||||
|
'active_status' => $activeStatus,
|
||||||
|
'count_sponsors_viewed' => $viewsCount,
|
||||||
|
'currently_working_sponsor' => $currentSponsor,
|
||||||
|
'latest_charity_events_list' => $charityEvents
|
||||||
|
]
|
||||||
|
], 200);
|
||||||
|
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
logger()->error('Worker Dashboard API Failure: ' . $e->getMessage());
|
||||||
|
|
||||||
|
return response()->json([
|
||||||
|
'success' => false,
|
||||||
|
'message' => 'An error occurred while loading the worker dashboard.',
|
||||||
|
'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error'
|
||||||
|
], 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
use App\Models\Conversation;
|
use App\Models\Conversation;
|
||||||
use App\Models\Message;
|
use App\Models\Message;
|
||||||
use App\Models\Worker;
|
use App\Models\Worker;
|
||||||
|
use App\Models\JobOffer;
|
||||||
|
|
||||||
class MessageController extends Controller
|
class MessageController extends Controller
|
||||||
{
|
{
|
||||||
@ -36,6 +37,62 @@ private function resolveCurrentUser()
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static function processWorkerResponse($conv, $worker, $text)
|
||||||
|
{
|
||||||
|
$replyText = strtolower(trim($text));
|
||||||
|
|
||||||
|
if ($replyText === 'yes' || $replyText === 'no') {
|
||||||
|
// Find the last message sent by the employer in this conversation (excluding the current worker message)
|
||||||
|
$lastEmployerMessage = Message::where('conversation_id', $conv->id)
|
||||||
|
->where('sender_type', 'employer')
|
||||||
|
->latest()
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($lastEmployerMessage) {
|
||||||
|
$questionText = strtolower($lastEmployerMessage->text);
|
||||||
|
$isLookingJobQuestion = str_contains($questionText, 'looking job') ||
|
||||||
|
str_contains($questionText, 'looking for a job') ||
|
||||||
|
str_contains($questionText, 'looking for job') ||
|
||||||
|
str_contains($questionText, 'are you looking');
|
||||||
|
|
||||||
|
if ($isLookingJobQuestion) {
|
||||||
|
if ($replyText === 'yes') {
|
||||||
|
// S6 Outcome: Update status as Hired
|
||||||
|
$worker->update([
|
||||||
|
'status' => 'Hired',
|
||||||
|
'availability' => 'Hired'
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Accept existing direct offer or application
|
||||||
|
$offer = JobOffer::where('employer_id', $conv->employer_id)
|
||||||
|
->where('worker_id', $worker->id)
|
||||||
|
->first();
|
||||||
|
|
||||||
|
if ($offer) {
|
||||||
|
$offer->update(['status' => 'accepted']);
|
||||||
|
} else {
|
||||||
|
JobOffer::create([
|
||||||
|
'employer_id' => $conv->employer_id,
|
||||||
|
'worker_id' => $worker->id,
|
||||||
|
'work_date' => now()->format('Y-m-d'),
|
||||||
|
'location' => 'Dubai',
|
||||||
|
'salary' => $worker->salary ?: 2000,
|
||||||
|
'notes' => 'Hired via chat agreement.',
|
||||||
|
'status' => 'accepted',
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
} else if ($replyText === 'no') {
|
||||||
|
// S5 Outcome: Update status as Hidden (Auto-Hidden from search)
|
||||||
|
$worker->update([
|
||||||
|
'status' => 'hidden',
|
||||||
|
'availability' => 'Not Available'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function index(Request $request)
|
public function index(Request $request)
|
||||||
{
|
{
|
||||||
$user = $this->resolveCurrentUser();
|
$user = $this->resolveCurrentUser();
|
||||||
@ -150,6 +207,32 @@ public function send(Request $request, $id)
|
|||||||
// Touch conversation updated_at for sorting
|
// Touch conversation updated_at for sorting
|
||||||
$conv->touch();
|
$conv->touch();
|
||||||
|
|
||||||
|
// Check if employer sent predefined message "are you looking job?"
|
||||||
|
$textLower = strtolower($request->text);
|
||||||
|
if (
|
||||||
|
str_contains($textLower, 'looking job') ||
|
||||||
|
str_contains($textLower, 'looking for a job') ||
|
||||||
|
str_contains($textLower, 'looking for job') ||
|
||||||
|
str_contains($textLower, 'are you looking')
|
||||||
|
) {
|
||||||
|
// Automatically simulate worker response 'Yes' (triggers the S6 hired flow)
|
||||||
|
$workerResponseText = 'Yes';
|
||||||
|
|
||||||
|
// Create the message in database
|
||||||
|
Message::create([
|
||||||
|
'conversation_id' => $conv->id,
|
||||||
|
'sender_type' => 'worker',
|
||||||
|
'sender_id' => $conv->worker_id,
|
||||||
|
'text' => $workerResponseText,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Process the worker response to update status to Hired!
|
||||||
|
$worker = Worker::find($conv->worker_id);
|
||||||
|
if ($worker) {
|
||||||
|
self::processWorkerResponse($conv, $worker, $workerResponseText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return back();
|
return back();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -10,6 +10,8 @@
|
|||||||
use App\Models\WorkerCategory;
|
use App\Models\WorkerCategory;
|
||||||
use App\Models\Shortlist;
|
use App\Models\Shortlist;
|
||||||
use App\Models\JobOffer;
|
use App\Models\JobOffer;
|
||||||
|
use App\Models\Review;
|
||||||
|
use App\Models\ProfileView;
|
||||||
|
|
||||||
class WorkerController extends Controller
|
class WorkerController extends Controller
|
||||||
{
|
{
|
||||||
@ -135,6 +137,15 @@ public function show($id)
|
|||||||
{
|
{
|
||||||
$w = Worker::with(['category', 'skills', 'documents'])->findOrFail($id);
|
$w = Worker::with(['category', 'skills', 'documents'])->findOrFail($id);
|
||||||
|
|
||||||
|
// Record employer profile view (Requirement 3)
|
||||||
|
$user = $this->resolveCurrentUser();
|
||||||
|
if ($user) {
|
||||||
|
ProfileView::updateOrCreate(
|
||||||
|
['employer_id' => $user->id, 'worker_id' => $w->id],
|
||||||
|
['updated_at' => now()]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
$langs = ($w->id % 2 === 0) ? ['English', 'Arabic'] : ( ($w->id % 3 === 0) ? ['English', 'Tagalog'] : ['English', 'Hindi', 'Arabic'] );
|
$langs = ($w->id % 2 === 0) ? ['English', 'Arabic'] : ( ($w->id % 3 === 0) ? ['English', 'Tagalog'] : ['English', 'Hindi', 'Arabic'] );
|
||||||
|
|
||||||
$jobTypes = ['full-time', 'part-time', 'live-in', 'live-out'];
|
$jobTypes = ['full-time', 'part-time', 'live-in', 'live-out'];
|
||||||
@ -158,9 +169,24 @@ public function show($id)
|
|||||||
];
|
];
|
||||||
$photo = $photos[$w->id % 4];
|
$photo = $photos[$w->id % 4];
|
||||||
|
|
||||||
|
// Fetch dynamic database reviews (Requirement 1)
|
||||||
|
$dbReviews = Review::where('worker_id', $w->id)->with('employer')->latest()->get();
|
||||||
|
$reviews = $dbReviews->map(function ($rev) {
|
||||||
|
return [
|
||||||
|
'id' => $rev->id,
|
||||||
|
'employer_name' => $rev->employer->name ?? 'Employer',
|
||||||
|
'rating' => $rev->rating,
|
||||||
|
'date' => $rev->created_at->format('M d, Y'),
|
||||||
|
'comment' => $rev->comment,
|
||||||
|
];
|
||||||
|
})->toArray();
|
||||||
|
|
||||||
|
$reviewsCount = count($reviews);
|
||||||
|
if ($reviewsCount > 0) {
|
||||||
|
$rating = round($dbReviews->avg('rating'), 1);
|
||||||
|
} else {
|
||||||
$rating = 4.0 + (($w->id * 3) % 10) / 10.0;
|
$rating = 4.0 + (($w->id * 3) % 10) / 10.0;
|
||||||
$reviewsCount = ($w->id * 4) % 20 + 2;
|
$reviewsCount = ($w->id * 4) % 20 + 2;
|
||||||
|
|
||||||
$reviews = [
|
$reviews = [
|
||||||
[
|
[
|
||||||
'id' => 1,
|
'id' => 1,
|
||||||
@ -177,6 +203,7 @@ public function show($id)
|
|||||||
'comment' => 'Very punctual, did exactly what was expected. Highly recommend.',
|
'comment' => 'Very punctual, did exactly what was expected. Highly recommend.',
|
||||||
]
|
]
|
||||||
];
|
];
|
||||||
|
}
|
||||||
|
|
||||||
$simDb = Worker::with('category')
|
$simDb = Worker::with('category')
|
||||||
->where('id', '!=', $w->id)
|
->where('id', '!=', $w->id)
|
||||||
|
|||||||
26
app/Models/ProfileView.php
Normal file
26
app/Models/ProfileView.php
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class ProfileView extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'employer_id',
|
||||||
|
'worker_id',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function employer()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'employer_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function worker()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Worker::class, 'worker_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
28
app/Models/Review.php
Normal file
28
app/Models/Review.php
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class Review extends Model
|
||||||
|
{
|
||||||
|
use HasFactory;
|
||||||
|
|
||||||
|
protected $fillable = [
|
||||||
|
'employer_id',
|
||||||
|
'worker_id',
|
||||||
|
'rating',
|
||||||
|
'comment',
|
||||||
|
];
|
||||||
|
|
||||||
|
public function employer()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(User::class, 'employer_id');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function worker()
|
||||||
|
{
|
||||||
|
return $this->belongsTo(Worker::class, 'worker_id');
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -60,4 +60,14 @@ public function offers()
|
|||||||
{
|
{
|
||||||
return $this->hasMany(JobOffer::class, 'worker_id');
|
return $this->hasMany(JobOffer::class, 'worker_id');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function reviews()
|
||||||
|
{
|
||||||
|
return $this->hasMany(Review::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function profileViews()
|
||||||
|
{
|
||||||
|
return $this->hasMany(ProfileView::class);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('reviews', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('employer_id')->constrained('users')->onDelete('cascade');
|
||||||
|
$table->foreignId('worker_id')->constrained('workers')->onDelete('cascade');
|
||||||
|
$table->integer('rating');
|
||||||
|
$table->text('comment')->nullable();
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
// Ensure an employer can only review a worker once
|
||||||
|
$table->unique(['employer_id', 'worker_id']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('reviews');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('profile_views', function (Blueprint $table) {
|
||||||
|
$table->id();
|
||||||
|
$table->foreignId('employer_id')->constrained('users')->onDelete('cascade');
|
||||||
|
$table->foreignId('worker_id')->constrained('workers')->onDelete('cascade');
|
||||||
|
$table->timestamps();
|
||||||
|
|
||||||
|
// Unique combination to track unique employer-worker views and update updated_at on duplicate
|
||||||
|
$table->unique(['employer_id', 'worker_id']);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse the migrations.
|
||||||
|
*/
|
||||||
|
public function down(): void
|
||||||
|
{
|
||||||
|
Schema::dropIfExists('profile_views');
|
||||||
|
}
|
||||||
|
};
|
||||||
@ -1854,7 +1854,162 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/employers/workers/{id}": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Employer/Pipeline"
|
||||||
|
],
|
||||||
|
"summary": "Get Worker Profile Detail",
|
||||||
|
"description": "Retrieves the complete profile details for a worker, mirroring the web detailed view, along with active conversation details if already started with this employer.",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"description": "Worker ID reference.",
|
||||||
|
"schema": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Worker profile details retrieved successfully.",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"success": {
|
||||||
|
"type": "boolean",
|
||||||
|
"example": true
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"worker": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "integer",
|
||||||
|
"example": 1
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Jane Doe"
|
||||||
|
},
|
||||||
|
"nationality": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Filipino"
|
||||||
|
},
|
||||||
|
"photo": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?auto=format&fit=crop&q=80&w=200"
|
||||||
|
},
|
||||||
|
"emirates_id_status": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Emirates ID Verified"
|
||||||
|
},
|
||||||
|
"category": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Domestic Worker"
|
||||||
|
},
|
||||||
|
"skills": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"example": ["childcare", "cooking"]
|
||||||
|
},
|
||||||
|
"availability_status": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Active"
|
||||||
|
},
|
||||||
|
"visa_status": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Residence Visa"
|
||||||
|
},
|
||||||
|
"experience": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "5 Years"
|
||||||
|
},
|
||||||
|
"experience_years": {
|
||||||
|
"type": "integer",
|
||||||
|
"example": 5
|
||||||
|
},
|
||||||
|
"salary": {
|
||||||
|
"type": "integer",
|
||||||
|
"example": 2500
|
||||||
|
},
|
||||||
|
"religion": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Christian"
|
||||||
|
},
|
||||||
|
"languages": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"example": ["English", "Tagalog"]
|
||||||
|
},
|
||||||
|
"age": {
|
||||||
|
"type": "integer",
|
||||||
|
"example": 28
|
||||||
|
},
|
||||||
|
"verified": {
|
||||||
|
"type": "boolean",
|
||||||
|
"example": true
|
||||||
|
},
|
||||||
|
"preferred_job_type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "live-in"
|
||||||
|
},
|
||||||
|
"bio": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Experienced and caring domestic worker specialing in childcare and housekeeping..."
|
||||||
|
},
|
||||||
|
"rating": {
|
||||||
|
"type": "number",
|
||||||
|
"example": 4.3
|
||||||
|
},
|
||||||
|
"reviews_count": {
|
||||||
|
"type": "integer",
|
||||||
|
"example": 6
|
||||||
|
},
|
||||||
|
"reviews": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"similar_workers": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"conversation_id": {
|
||||||
|
"type": "integer",
|
||||||
|
"nullable": true,
|
||||||
|
"example": 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Worker profile not found."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/employers/candidates": {
|
"/employers/candidates": {
|
||||||
|
|
||||||
"get": {
|
"get": {
|
||||||
"tags": [
|
"tags": [
|
||||||
"Employer/Pipeline"
|
"Employer/Pipeline"
|
||||||
@ -1907,6 +2062,318 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"/workers/dashboard/views": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Worker/Dashboard"
|
||||||
|
],
|
||||||
|
"summary": "Get Worker Profile Views Statistics",
|
||||||
|
"description": "Allows authenticated workers to see the total count and detailed list of employers/sponsors who viewed their full profile.",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Profile views statistics retrieved successfully.",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"success": {
|
||||||
|
"type": "boolean",
|
||||||
|
"example": true
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"views_count": {
|
||||||
|
"type": "integer",
|
||||||
|
"example": 5
|
||||||
|
},
|
||||||
|
"views_list": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "integer",
|
||||||
|
"example": 1
|
||||||
|
},
|
||||||
|
"employer_id": {
|
||||||
|
"type": "integer",
|
||||||
|
"example": 2
|
||||||
|
},
|
||||||
|
"employer_name": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Ahmad"
|
||||||
|
},
|
||||||
|
"company_name": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Ahmad Tech Ltd"
|
||||||
|
},
|
||||||
|
"nationality": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "UAE"
|
||||||
|
},
|
||||||
|
"city": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Dubai"
|
||||||
|
},
|
||||||
|
"viewed_at": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"example": "2026-06-01T15:10:24.000000Z"
|
||||||
|
},
|
||||||
|
"viewed_at_formatted": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "2 minutes ago"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/workers/dashboard": {
|
||||||
|
"get": {
|
||||||
|
"tags": [
|
||||||
|
"Worker/Dashboard"
|
||||||
|
],
|
||||||
|
"summary": "Get Worker Consolidated Dashboard Data",
|
||||||
|
"description": "Allows authenticated workers to retrieve their dashboard metrics: active status, profile views count, currently working sponsor/employer details, and latest charity events/announcements.",
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Worker dashboard details retrieved successfully.",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"success": {
|
||||||
|
"type": "boolean",
|
||||||
|
"example": true
|
||||||
|
},
|
||||||
|
"data": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"active_status": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "active"
|
||||||
|
},
|
||||||
|
"count_sponsors_viewed": {
|
||||||
|
"type": "integer",
|
||||||
|
"example": 5
|
||||||
|
},
|
||||||
|
"currently_working_sponsor": {
|
||||||
|
"type": "object",
|
||||||
|
"nullable": true,
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "integer",
|
||||||
|
"example": 2
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Ahmad"
|
||||||
|
},
|
||||||
|
"company_name": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Ahmad Tech Ltd"
|
||||||
|
},
|
||||||
|
"nationality": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "UAE"
|
||||||
|
},
|
||||||
|
"city": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Dubai"
|
||||||
|
},
|
||||||
|
"email": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "ahmad@example.com"
|
||||||
|
},
|
||||||
|
"phone": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "+971509990001"
|
||||||
|
},
|
||||||
|
"hired_at": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"example": "2026-06-01T15:10:24.000000Z"
|
||||||
|
},
|
||||||
|
"hired_at_formatted": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Jun 01, 2026"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"latest_charity_events_list": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "integer",
|
||||||
|
"example": 1
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Free Dental Checkup Camp"
|
||||||
|
},
|
||||||
|
"body": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Emirates Charity is providing free screening."
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "charity"
|
||||||
|
},
|
||||||
|
"employer_name": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Emirates Charity"
|
||||||
|
},
|
||||||
|
"company_name": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Emirates Charity Foundation"
|
||||||
|
},
|
||||||
|
"created_at": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"example": "2026-06-01T10:00:00.000000Z"
|
||||||
|
},
|
||||||
|
"time_ago": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "5 hours ago"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/employers/reviews": {
|
||||||
|
"post": {
|
||||||
|
"tags": [
|
||||||
|
"Employer/Reviews"
|
||||||
|
],
|
||||||
|
"summary": "Add or Update Worker Review",
|
||||||
|
"description": "Allows employers to submit a review score and description comment for a worker. If the employer has already left a review, this will update it.",
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"worker_id",
|
||||||
|
"rating"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"worker_id": {
|
||||||
|
"type": "integer",
|
||||||
|
"example": 136,
|
||||||
|
"description": "Worker ID being reviewed."
|
||||||
|
},
|
||||||
|
"rating": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 5,
|
||||||
|
"example": 5,
|
||||||
|
"description": "Review score from 1 to 5."
|
||||||
|
},
|
||||||
|
"comment": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Highly professional, punctual and did a great job!",
|
||||||
|
"description": "Optional detailed comment."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"201": {
|
||||||
|
"description": "Review added successfully."
|
||||||
|
},
|
||||||
|
"200": {
|
||||||
|
"description": "Review updated successfully."
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"description": "Validation error."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/employers/reviews/{id}": {
|
||||||
|
"put": {
|
||||||
|
"tags": [
|
||||||
|
"Employer/Reviews"
|
||||||
|
],
|
||||||
|
"summary": "Edit Worker Review",
|
||||||
|
"description": "Allows employers to edit their previously created review score and comment by review ID.",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true,
|
||||||
|
"description": "Review ID reference.",
|
||||||
|
"schema": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"rating"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"rating": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 5,
|
||||||
|
"example": 4,
|
||||||
|
"description": "Updated rating score from 1 to 5."
|
||||||
|
},
|
||||||
|
"comment": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Updated feedback comment.",
|
||||||
|
"description": "Updated review comment description."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Review edited successfully."
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Review not found or unauthorized."
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"description": "Validation error."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"components": {
|
"components": {
|
||||||
|
|||||||
@ -2,321 +2,288 @@ import React, { useState } from 'react';
|
|||||||
import { Head } from '@inertiajs/react';
|
import { Head } from '@inertiajs/react';
|
||||||
import AdminLayout from '@/Layouts/AdminLayout';
|
import AdminLayout from '@/Layouts/AdminLayout';
|
||||||
import {
|
import {
|
||||||
BarChart3,
|
FileText,
|
||||||
TrendingUp,
|
|
||||||
Users,
|
Users,
|
||||||
|
ShieldAlert,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
Percent,
|
Search,
|
||||||
Award,
|
RefreshCw,
|
||||||
Sparkles,
|
Download,
|
||||||
Calendar,
|
Building2,
|
||||||
Download
|
DollarSign
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
|
|
||||||
export default function AnalyticsHub({ user_growth, revenue_breakdown, retention_rates, hiring_funnel }) {
|
export default function AnalyticsHub({
|
||||||
const [selectedTab, setSelectedTab] = useState('Growth');
|
worker_stats = { total: 0, verified: 0, active: 0, inactive: 0 },
|
||||||
|
sponsor_stats = { total: 0, verified: 0, active_subscribers: 0, unpaid: 0 },
|
||||||
|
dispute_stats = { total: 0, open: 0, under_review: 0, resolved: 0 },
|
||||||
|
safety_stats = { total: 0, pending: 0, resolved: 0 },
|
||||||
|
payments = [],
|
||||||
|
total_revenue = 0
|
||||||
|
}) {
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
const [statusFilter, setStatusFilter] = useState('all');
|
||||||
|
|
||||||
// Helper coordinates calculation for Cohort growth Area Chart (450w, 200h)
|
// Filter payments dynamically
|
||||||
const getCoordinatesForArea = (key, maxVal) => {
|
const filteredPayments = payments.filter(payment => {
|
||||||
const points = [];
|
const matchesSearch =
|
||||||
const paddingLeft = 40;
|
payment.id.toString().toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
const paddingRight = 15;
|
payment.user_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
const paddingTop = 20;
|
payment.user_email.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||||
const paddingBottom = 25;
|
payment.description.toLowerCase().includes(searchTerm.toLowerCase());
|
||||||
|
|
||||||
const graphHeight = 200 - paddingTop - paddingBottom;
|
const matchesStatus = statusFilter === 'all' || payment.status.toLowerCase() === statusFilter.toLowerCase();
|
||||||
const graphWidth = 450 - paddingLeft - paddingRight;
|
|
||||||
|
|
||||||
user_growth.forEach((d, idx) => {
|
return matchesSearch && matchesStatus;
|
||||||
const val = d[key];
|
|
||||||
const x = paddingLeft + (idx / (user_growth.length - 1)) * graphWidth;
|
|
||||||
const y = paddingTop + graphHeight - (val / maxVal) * graphHeight;
|
|
||||||
points.push(`${x},${y}`);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add bottom-right and bottom-left to close the area shape
|
const handleExportCSV = () => {
|
||||||
const startX = paddingLeft;
|
const headers = ['Payment ID,Customer,Email,Details,Amount,Status,Date\n'];
|
||||||
const endX = paddingLeft + graphWidth;
|
const rows = filteredPayments.map(p =>
|
||||||
const bottomY = paddingTop + graphHeight;
|
`"${p.id}","${p.user_name}","${p.user_email}","${p.description}",${p.amount},"${p.status}","${p.created_at}"`
|
||||||
|
);
|
||||||
return `${startX},${bottomY} ${points.join(' ')} ${endX},${bottomY}`;
|
const blob = new Blob([headers.concat(rows.join('\n'))], { type: 'text/csv;charset=utf-8;' });
|
||||||
};
|
const link = document.createElement('a');
|
||||||
|
link.href = URL.createObjectURL(blob);
|
||||||
const getLineCoordinates = (key, maxVal) => {
|
link.setAttribute('download', `financial_report_${new Date().toISOString().split('T')[0]}.csv`);
|
||||||
const points = [];
|
document.body.appendChild(link);
|
||||||
const paddingLeft = 40;
|
link.click();
|
||||||
const paddingRight = 15;
|
document.body.removeChild(link);
|
||||||
const paddingTop = 20;
|
|
||||||
const paddingBottom = 25;
|
|
||||||
|
|
||||||
const graphHeight = 200 - paddingTop - paddingBottom;
|
|
||||||
const graphWidth = 450 - paddingLeft - paddingRight;
|
|
||||||
|
|
||||||
user_growth.forEach((d, idx) => {
|
|
||||||
const val = d[key];
|
|
||||||
const x = paddingLeft + (idx / (user_growth.length - 1)) * graphWidth;
|
|
||||||
const y = paddingTop + graphHeight - (val / maxVal) * graphHeight;
|
|
||||||
points.push(`${x},${y}`);
|
|
||||||
});
|
|
||||||
return points.join(' ');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminLayout title="Reports & Analytics Hub">
|
<AdminLayout title="Reports">
|
||||||
<Head title="System Analytics" />
|
<Head title="Reports" />
|
||||||
|
|
||||||
<div className="font-sans max-w-7xl mx-auto space-y-8">
|
<div className="font-sans max-w-7xl mx-auto space-y-8 pb-12">
|
||||||
|
|
||||||
{/* Title and date picker */}
|
{/* Simplified Header */}
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 border-b border-slate-200 pb-5">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-bold text-gray-900 tracking-tight">Interactive Platform Reports & Analytics Hub</h1>
|
<h1 className="text-xl font-black text-slate-800 uppercase tracking-tight">Platform Reports</h1>
|
||||||
<p className="text-xs text-gray-500 mt-0.5">Explore subscription cohort retention, job conversions ratios, availability trends, and monthly billing splits.</p>
|
<p className="text-xs text-slate-500 mt-0.5 font-medium">Summary of workers, sponsors, disputes, and payments.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex bg-slate-200/60 p-1 rounded-xl w-fit border border-slate-200">
|
|
||||||
{['Growth', 'Revenue', 'Funnel'].map((tab) => (
|
|
||||||
<button
|
<button
|
||||||
key={tab}
|
onClick={() => window.location.reload()}
|
||||||
type="button"
|
className="inline-flex items-center px-3.5 py-2 bg-white border border-slate-200 text-slate-700 rounded-xl text-xs font-black uppercase tracking-wider hover:bg-slate-50 transition-colors shadow-sm gap-1.5"
|
||||||
onClick={() => setSelectedTab(tab)}
|
|
||||||
className={`px-4 py-1.5 rounded-lg text-[10px] font-black uppercase tracking-wider transition-all ${
|
|
||||||
selectedTab === tab
|
|
||||||
? 'bg-white text-[#0F6E56] shadow-sm'
|
|
||||||
: 'text-slate-600 hover:text-slate-900'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{tab} Report
|
<RefreshCw className="w-3.5 h-3.5" />
|
||||||
|
<span>Refresh</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
<button
|
||||||
</div>
|
onClick={handleExportCSV}
|
||||||
<button className="inline-flex items-center px-4 py-2.5 bg-slate-950 text-white rounded-xl text-xs font-bold hover:bg-slate-900 transition-colors shadow-sm space-x-2 uppercase tracking-wider">
|
className="inline-flex items-center px-4 py-2 bg-[#0F6E56] hover:bg-[#085041] text-white rounded-xl text-xs font-black uppercase tracking-wider transition-all shadow-md gap-1.5"
|
||||||
<Download className="w-4 h-4" />
|
>
|
||||||
<span>Export PDF</span>
|
<Download className="w-3.5 h-3.5" />
|
||||||
|
<span>Export CSV</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Analytical Charts grid depending on tabs */}
|
{/* Simplified Summary Cards */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
|
|
||||||
{/* Growth cohort Report */}
|
{/* Workers Card */}
|
||||||
{selectedTab === 'Growth' && (
|
<div className="bg-white border border-slate-200 rounded-2xl p-5 shadow-sm space-y-4 hover:border-[#0F6E56]/30 transition-all">
|
||||||
<div className="bg-white border border-slate-200 rounded-3xl p-6 shadow-sm space-y-6">
|
<div className="flex justify-between items-start">
|
||||||
|
<div className="p-2.5 bg-teal-50 rounded-xl text-[#0F6E56]">
|
||||||
|
<Users className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] bg-slate-105 text-[#0F6E56] font-extrabold uppercase px-2 py-0.5 rounded tracking-wide">
|
||||||
|
Workers
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-black text-slate-800 uppercase tracking-widest flex items-center gap-1.5">
|
<span className="text-[10px] text-slate-400 font-bold uppercase tracking-wider block">Total Workers</span>
|
||||||
<TrendingUp className="w-4.5 h-4.5 text-[#0F6E56]" />
|
<span className="text-3xl font-black text-slate-800 block mt-1 tracking-tight">{worker_stats.total}</span>
|
||||||
<span>User Cohort growth curves</span>
|
|
||||||
</h3>
|
|
||||||
<p className="text-[10px] text-slate-400 mt-0.5 uppercase tracking-wider font-bold">Employer and Worker registry trends (6 Months)</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="pt-2 border-t border-slate-100 grid grid-cols-2 gap-2 text-[10px] font-bold text-slate-500">
|
||||||
<div className="relative">
|
|
||||||
<svg className="w-full h-56" viewBox="0 0 450 200">
|
|
||||||
{/* Grids */}
|
|
||||||
<line x1="40" y1="20" x2="435" y2="20" stroke="#f8fafc" strokeWidth="1.5" />
|
|
||||||
<line x1="40" y1="75" x2="435" y2="75" stroke="#f8fafc" strokeWidth="1.5" />
|
|
||||||
<line x1="40" y1="130" x2="435" y2="130" stroke="#f8fafc" strokeWidth="1.5" />
|
|
||||||
<line x1="40" y1="180" x2="435" y2="180" stroke="#cbd5e1" strokeWidth="1.5" />
|
|
||||||
|
|
||||||
{/* Worker Area fill */}
|
|
||||||
<polygon
|
|
||||||
fill="url(#workerGrad)"
|
|
||||||
points={getCoordinatesForArea('workers', 1500)}
|
|
||||||
className="opacity-45"
|
|
||||||
/>
|
|
||||||
{/* Worker Line */}
|
|
||||||
<polyline
|
|
||||||
fill="none"
|
|
||||||
stroke="#10b981"
|
|
||||||
strokeWidth="4"
|
|
||||||
points={getLineCoordinates('workers', 1500)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Employer Line */}
|
|
||||||
<polyline
|
|
||||||
fill="none"
|
|
||||||
stroke="#3b82f6"
|
|
||||||
strokeWidth="3.5"
|
|
||||||
strokeDasharray="4 4"
|
|
||||||
points={getLineCoordinates('employers', 1500)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Gradients */}
|
|
||||||
<defs>
|
|
||||||
<linearGradient id="workerGrad" x1="0" y1="0" x2="0" y2="1">
|
|
||||||
<stop offset="0%" stopColor="#10b981" />
|
|
||||||
<stop offset="100%" stopColor="#ffffff" />
|
|
||||||
</linearGradient>
|
|
||||||
</defs>
|
|
||||||
|
|
||||||
{/* X-Axis Month labels */}
|
|
||||||
{user_growth.map((d, i) => {
|
|
||||||
const x = 40 + (i / 5) * 380;
|
|
||||||
return (
|
|
||||||
<text key={i} x={x} y="195" textAnchor="middle" className="text-[9px] font-bold fill-slate-400 font-mono">
|
|
||||||
{d.month}
|
|
||||||
</text>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{/* Y Labels */}
|
|
||||||
<text x="30" y="24" textAnchor="end" className="text-[9px] font-black fill-slate-300">1.5K</text>
|
|
||||||
<text x="30" y="80" textAnchor="end" className="text-[9px] font-black fill-slate-300">750</text>
|
|
||||||
<text x="30" y="135" textAnchor="end" className="text-[9px] font-black fill-slate-300">300</text>
|
|
||||||
<text x="30" y="185" textAnchor="end" className="text-[9px] font-black fill-slate-300">0</text>
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-center space-x-6 mt-4">
|
|
||||||
<div className="flex items-center space-x-2 text-xs font-bold text-slate-600">
|
|
||||||
<div className="w-3.5 h-3.5 bg-emerald-500 rounded" />
|
|
||||||
<span>Workers ({user_growth[5].workers})</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2 text-xs font-bold text-slate-600">
|
|
||||||
<div className="w-3.5 h-1 border-t-2 border-dashed border-blue-500" />
|
|
||||||
<span>Employers ({user_growth[5].employers})</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Revenue reports */}
|
|
||||||
{selectedTab === 'Revenue' && (
|
|
||||||
<div className="bg-white border border-slate-200 rounded-3xl p-6 shadow-sm space-y-6">
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-black text-slate-800 uppercase tracking-widest flex items-center gap-1.5">
|
<span className="text-slate-400 uppercase tracking-widest text-[8px] block">Verified</span>
|
||||||
|
<span className="text-slate-700 font-extrabold text-xs">{worker_stats.verified}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-400 uppercase tracking-widest text-[8px] block">Active</span>
|
||||||
|
<span className="text-slate-700 font-extrabold text-xs">{worker_stats.active}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sponsors Card */}
|
||||||
|
<div className="bg-white border border-slate-200 rounded-2xl p-5 shadow-sm space-y-4 hover:border-[#0F6E56]/30 transition-all">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div className="p-2.5 bg-blue-50 rounded-xl text-blue-600">
|
||||||
|
<Building2 className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] bg-blue-50 text-blue-700 font-extrabold uppercase px-2 py-0.5 rounded tracking-wide">
|
||||||
|
Sponsors
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-[10px] text-slate-400 font-bold uppercase tracking-wider block">Total Sponsors</span>
|
||||||
|
<span className="text-3xl font-black text-slate-800 block mt-1 tracking-tight">{sponsor_stats.total}</span>
|
||||||
|
</div>
|
||||||
|
<div className="pt-2 border-t border-slate-100 grid grid-cols-2 gap-2 text-[10px] font-bold text-slate-500">
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-400 uppercase tracking-widest text-[8px] block">Active Plan</span>
|
||||||
|
<span className="text-slate-700 font-extrabold text-xs">{sponsor_stats.active_subscribers}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-400 uppercase tracking-widest text-[8px] block">Unpaid</span>
|
||||||
|
<span className="text-slate-700 font-extrabold text-xs">{sponsor_stats.unpaid}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Disputes Card */}
|
||||||
|
<div className="bg-white border border-slate-200 rounded-2xl p-5 shadow-sm space-y-4 hover:border-[#0F6E56]/30 transition-all">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div className="p-2.5 bg-orange-50 rounded-xl text-orange-600">
|
||||||
|
<ShieldAlert className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] bg-orange-50 text-orange-700 font-extrabold uppercase px-2 py-0.5 rounded tracking-wide">
|
||||||
|
Disputes
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-[10px] text-slate-400 font-bold uppercase tracking-wider block">Total Disputes</span>
|
||||||
|
<span className="text-3xl font-black text-slate-800 block mt-1 tracking-tight">{dispute_stats.open + dispute_stats.under_review}</span>
|
||||||
|
</div>
|
||||||
|
<div className="pt-2 border-t border-slate-100 grid grid-cols-2 gap-2 text-[10px] font-bold text-slate-500">
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-400 uppercase tracking-widest text-[8px] block">Resolved</span>
|
||||||
|
<span className="text-slate-700 font-extrabold text-xs">{dispute_stats.resolved}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-400 uppercase tracking-widest text-[8px] block">Pending Flags</span>
|
||||||
|
<span className="text-slate-700 font-extrabold text-xs">{safety_stats.pending}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Revenue Card */}
|
||||||
|
<div className="bg-white border border-slate-200 rounded-2xl p-5 shadow-sm space-y-4 hover:border-[#0F6E56]/30 transition-all">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<div className="p-2.5 bg-emerald-50 rounded-xl text-emerald-600">
|
||||||
|
<DollarSign className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] bg-emerald-50 text-emerald-700 font-extrabold uppercase px-2 py-0.5 rounded tracking-wide">
|
||||||
|
Revenue
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-[10px] text-slate-400 font-bold uppercase tracking-wider block">Total Revenue</span>
|
||||||
|
<span className="text-3xl font-black text-slate-800 block mt-1 tracking-tight">
|
||||||
|
AED {total_revenue.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="pt-2 border-t border-slate-100 grid grid-cols-2 gap-2 text-[10px] font-bold text-slate-500">
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-400 uppercase tracking-widest text-[8px] block">Total Paid</span>
|
||||||
|
<span className="text-slate-700 font-extrabold text-xs">{payments.length}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-400 uppercase tracking-widest text-[8px] block">Average Amount</span>
|
||||||
|
<span className="text-slate-700 font-extrabold text-xs">
|
||||||
|
AED {payments.length > 0 ? (total_revenue / payments.length).toFixed(0) : '0'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Simplified Payment Ledger */}
|
||||||
|
<div className="bg-white border border-slate-200 rounded-3xl p-6 shadow-sm space-y-6">
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 border-b border-slate-100 pb-5">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-black text-slate-800 uppercase tracking-widest flex items-center gap-2">
|
||||||
<CreditCard className="w-4.5 h-4.5 text-[#0F6E56]" />
|
<CreditCard className="w-4.5 h-4.5 text-[#0F6E56]" />
|
||||||
<span>Stacked Subscription revenue splits</span>
|
<span>Payment List</span>
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-[10px] text-slate-400 mt-0.5 uppercase tracking-wider font-bold">Monthly billing splits (AED)</p>
|
<p className="text-[10px] text-slate-400 font-semibold mt-0.5">Search and check payments.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Search & Status Filters */}
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<svg className="w-full h-56" viewBox="0 0 450 200">
|
<Search className="w-4 h-4 text-slate-400 absolute left-3.5 top-1/2 -translate-y-1/2" />
|
||||||
<line x1="40" y1="180" x2="435" y2="180" stroke="#cbd5e1" strokeWidth="1.5" />
|
<input
|
||||||
|
type="text"
|
||||||
{/* Stacked bars for each month */}
|
placeholder="Search payments..."
|
||||||
{revenue_breakdown.map((d, i) => {
|
className="bg-slate-50 border border-slate-200 rounded-xl pl-9 pr-4 py-2 text-xs font-semibold focus:bg-white outline-none focus:ring-2 focus:ring-[#0F6E56]/10 w-64 shadow-sm"
|
||||||
const x = 50 + i * 62;
|
value={searchTerm}
|
||||||
const total = d.basic + d.premium + d.vip;
|
onChange={e => setSearchTerm(e.target.value)}
|
||||||
const maxVal = 80000;
|
|
||||||
const scale = 150; // Graph height scale factor
|
|
||||||
|
|
||||||
const basicH = (d.basic / maxVal) * scale;
|
|
||||||
const premiumH = (d.premium / maxVal) * scale;
|
|
||||||
const vipH = (d.vip / maxVal) * scale;
|
|
||||||
|
|
||||||
const basicY = 180 - basicH;
|
|
||||||
const premiumY = basicY - premiumH;
|
|
||||||
const vipY = premiumY - vipH;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<g key={i} className="cursor-pointer group">
|
|
||||||
{/* Basic */}
|
|
||||||
<rect x={x} y={basicY} width="22" height={basicH} fill="#94a3b8" rx="2" />
|
|
||||||
{/* Premium */}
|
|
||||||
<rect x={x} y={premiumY} width="22" height={premiumH} fill="#3b82f6" />
|
|
||||||
{/* VIP */}
|
|
||||||
<rect x={x} y={vipY} width="22" height={vipH} fill="#10b981" rx="2" />
|
|
||||||
|
|
||||||
<text x={x + 11} y="195" textAnchor="middle" className="text-[9px] font-bold fill-slate-400 font-mono">
|
|
||||||
{d.month.split(' ')[0]}
|
|
||||||
</text>
|
|
||||||
</g>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
<text x="30" y="30" textAnchor="end" className="text-[9px] font-black fill-slate-300">80K</text>
|
|
||||||
<text x="30" y="105" textAnchor="end" className="text-[9px] font-black fill-slate-300">40K</text>
|
|
||||||
<text x="30" y="180" textAnchor="end" className="text-[9px] font-black fill-slate-300">0</text>
|
|
||||||
</svg>
|
|
||||||
|
|
||||||
<div className="flex items-center justify-center space-x-6 mt-4">
|
|
||||||
<div className="flex items-center space-x-2 text-xs font-bold text-slate-600">
|
|
||||||
<div className="w-3 h-3 bg-slate-400 rounded-sm" />
|
|
||||||
<span>Basic</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2 text-xs font-bold text-slate-600">
|
|
||||||
<div className="w-3 h-3 bg-blue-500 rounded-sm" />
|
|
||||||
<span>Premium</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2 text-xs font-bold text-slate-600">
|
|
||||||
<div className="w-3 h-3 bg-emerald-500 rounded-sm" />
|
|
||||||
<span>VIP</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Funnel Conversions */}
|
|
||||||
{selectedTab === 'Funnel' && (
|
|
||||||
<div className="bg-white border border-slate-200 rounded-3xl p-6 shadow-sm space-y-6">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-black text-slate-800 uppercase tracking-widest flex items-center gap-1.5">
|
|
||||||
<Percent className="w-4.5 h-4.5 text-[#0F6E56]" />
|
|
||||||
<span>Hiring Placement Funnel ratios</span>
|
|
||||||
</h3>
|
|
||||||
<p className="text-[10px] text-slate-400 mt-0.5 uppercase tracking-wider font-bold">Hiring Conversion stage details (This month)</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
{hiring_funnel.map((item, idx) => {
|
|
||||||
const maxVal = hiring_funnel[0].count;
|
|
||||||
const pct = ((item.count / maxVal) * 100).toFixed(1);
|
|
||||||
return (
|
|
||||||
<div key={idx} className="space-y-2 text-xs font-bold text-slate-700">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span>{item.stage}</span>
|
|
||||||
<span>{item.count.toLocaleString()} ({pct}%)</span>
|
|
||||||
</div>
|
|
||||||
<div className="w-full bg-slate-100 rounded-full h-3.5 overflow-hidden">
|
|
||||||
<div
|
|
||||||
className="h-full rounded-full bg-gradient-to-r from-teal-600 to-teal-500 transition-all duration-500"
|
|
||||||
style={{ width: `${pct}%` }}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Right side cohort Retention Analysis: Month-over-Month curves */}
|
<select
|
||||||
<div className="bg-white border border-slate-200 rounded-3xl p-6 shadow-sm flex flex-col justify-between">
|
className="bg-slate-50 border border-slate-200 rounded-xl px-4 py-2 text-xs font-semibold outline-none cursor-pointer focus:bg-white focus:ring-2 focus:ring-[#0F6E56]/10 shadow-sm"
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={e => setStatusFilter(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="all">All Payments</option>
|
||||||
|
<option value="success">Success</option>
|
||||||
|
<option value="pending">Pending</option>
|
||||||
|
<option value="failed">Failed</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Payments Table */}
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-left border-collapse">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-slate-150 text-[10px] text-slate-400 font-black uppercase tracking-widest">
|
||||||
|
<th className="px-5 py-3">Payment ID</th>
|
||||||
|
<th className="px-5 py-3">Customer</th>
|
||||||
|
<th className="px-5 py-3">Details</th>
|
||||||
|
<th className="px-5 py-3">Amount</th>
|
||||||
|
<th className="px-5 py-3">Status</th>
|
||||||
|
<th className="px-5 py-3">Date</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-100 text-xs font-bold text-slate-700">
|
||||||
|
{filteredPayments.map(p => (
|
||||||
|
<tr key={p.id} className="hover:bg-slate-50/50 transition-colors">
|
||||||
|
<td className="px-5 py-4 font-mono text-[#0F6E56]">TXN-{p.id}</td>
|
||||||
|
<td className="px-5 py-4">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-black text-slate-800 uppercase tracking-widest flex items-center gap-1.5">
|
<span className="font-extrabold text-slate-800 block">{p.user_name}</span>
|
||||||
<Award className="w-4.5 h-4.5 text-[#0F6E56]" />
|
<span className="text-[10px] text-slate-400 font-semibold block">{p.user_email}</span>
|
||||||
<span>Subscription Cohort Retention rates</span>
|
|
||||||
</h3>
|
|
||||||
<p className="text-[10px] text-slate-400 mt-0.5 uppercase tracking-wider font-bold">Month 1 to Month 6 user retention metrics</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-4 my-6">
|
|
||||||
{retention_rates.map((cohort, i) => (
|
|
||||||
<div key={i} className="flex items-center justify-between text-xs font-bold text-slate-700">
|
|
||||||
<span className="w-20">{cohort.cohort}</span>
|
|
||||||
<div className="flex-1 mx-4 bg-slate-100 rounded-full h-3 overflow-hidden">
|
|
||||||
<div
|
|
||||||
className="h-full bg-gradient-to-r from-blue-600 to-blue-500 rounded-full"
|
|
||||||
style={{ width: `${cohort.rate}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<span className="text-blue-600 w-10 text-right">{cohort.rate}%</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-4 font-semibold text-slate-650">{p.description}</td>
|
||||||
|
<td className="px-5 py-4 font-extrabold text-slate-800">
|
||||||
|
{p.amount.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} {p.currency}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-4">
|
||||||
|
<span className={`px-2 py-0.5 rounded-full text-[9px] font-black uppercase tracking-wide ${
|
||||||
|
p.status === 'success' ? 'bg-emerald-50 text-emerald-700 border border-emerald-100' :
|
||||||
|
p.status === 'pending' ? 'bg-amber-50 text-amber-700 border border-amber-100' :
|
||||||
|
'bg-red-50 text-red-700 border border-red-100'
|
||||||
|
}`}>
|
||||||
|
{p.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-4 text-slate-400 font-semibold">{p.created_at}</td>
|
||||||
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{filteredPayments.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan="6" className="p-12 text-center text-slate-400 font-bold uppercase">
|
||||||
|
No payments found.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="bg-emerald-50/50 p-4 border border-emerald-100 rounded-2xl text-[11px] font-bold text-teal-800 leading-relaxed shadow-sm">
|
|
||||||
<Sparkles className="w-4 h-4 text-emerald-600 mb-1" />
|
|
||||||
<span>System Insights: Employer VIP and Premium tier subscribers yield a 79% retention rate beyond Month 3, validating platform organic vetting value.</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</AdminLayout>
|
</AdminLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -3,17 +3,9 @@ import { Head, router } from '@inertiajs/react';
|
|||||||
import AdminLayout from '@/Layouts/AdminLayout';
|
import AdminLayout from '@/Layouts/AdminLayout';
|
||||||
import {
|
import {
|
||||||
Search,
|
Search,
|
||||||
Filter,
|
|
||||||
History,
|
|
||||||
ShieldCheck,
|
|
||||||
User,
|
User,
|
||||||
CreditCard,
|
|
||||||
Scale,
|
|
||||||
AlertTriangle,
|
|
||||||
Eye,
|
|
||||||
Globe,
|
Globe,
|
||||||
Terminal,
|
Terminal
|
||||||
ArrowRight
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
@ -25,7 +17,7 @@ import {
|
|||||||
} from '@/components/ui/table';
|
} from '@/components/ui/table';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
|
||||||
export default function AuditLogsHub({ logs, search, category }) {
|
export default function AuditLogsHub({ logs = [], search = '', category = 'all' }) {
|
||||||
const [searchTerm, setSearchTerm] = useState(search || '');
|
const [searchTerm, setSearchTerm] = useState(search || '');
|
||||||
const [selectedCategory, setSelectedCategory] = useState(category || 'all');
|
const [selectedCategory, setSelectedCategory] = useState(category || 'all');
|
||||||
|
|
||||||
@ -48,16 +40,16 @@ export default function AuditLogsHub({ logs, search, category }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminLayout title="System Compliance Audit Logs">
|
<AdminLayout title="Logs">
|
||||||
<Head title="System Audit Logs" />
|
<Head title="Logs" />
|
||||||
|
|
||||||
<div className="font-sans max-w-7xl mx-auto space-y-6">
|
<div className="font-sans max-w-7xl mx-auto space-y-6 pb-12">
|
||||||
|
|
||||||
{/* Header text */}
|
{/* Simplified Header */}
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 border-b border-slate-200 pb-5">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-bold text-gray-900 tracking-tight">Centralized Compliance & Security Audit Logs</h1>
|
<h1 className="text-xl font-black text-slate-800 uppercase tracking-tight">System Logs</h1>
|
||||||
<p className="text-xs text-gray-500 mt-0.5">Track every critical administrative event, auto-OCR verification, stripe checkout charge, and user moderation updates.</p>
|
<p className="text-xs text-slate-500 mt-0.5 font-medium">Detailed history of system events.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -65,17 +57,17 @@ export default function AuditLogsHub({ logs, search, category }) {
|
|||||||
<div className="bg-white p-4 rounded-2xl border border-slate-200 shadow-sm flex flex-col md:flex-row gap-4 items-center justify-between">
|
<div className="bg-white p-4 rounded-2xl border border-slate-200 shadow-sm flex flex-col md:flex-row gap-4 items-center justify-between">
|
||||||
<form onSubmit={handleSearchSubmit} className="flex flex-1 items-center space-x-3 w-full md:w-auto">
|
<form onSubmit={handleSearchSubmit} className="flex flex-1 items-center space-x-3 w-full md:w-auto">
|
||||||
<div className="relative flex-1 max-w-md">
|
<div className="relative flex-1 max-w-md">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Search logs by IP, actor, message or ticket ID..."
|
placeholder="Search logs..."
|
||||||
className="w-full pl-10 pr-4 py-2.5 bg-slate-50 border border-slate-100 rounded-xl text-sm font-medium focus:bg-white focus:ring-4 focus:ring-teal-500/10 outline-none transition-all"
|
className="w-full pl-10 pr-4 py-2.5 bg-slate-50 border border-slate-100 rounded-xl text-xs font-semibold focus:bg-white focus:ring-2 focus:ring-[#0F6E56]/10 outline-none transition-all"
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={e => setSearchTerm(e.target.value)}
|
onChange={e => setSearchTerm(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" className="px-4 py-2.5 bg-slate-900 hover:bg-slate-800 text-white rounded-xl text-xs font-black uppercase tracking-wider">
|
<button type="submit" className="px-4 py-2.5 bg-[#0F6E56] hover:bg-[#085041] text-white rounded-xl text-xs font-black uppercase tracking-wider transition-colors">
|
||||||
Search Logs
|
Search
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@ -84,10 +76,10 @@ export default function AuditLogsHub({ logs, search, category }) {
|
|||||||
{[
|
{[
|
||||||
{ label: 'All Logs', val: 'all' },
|
{ label: 'All Logs', val: 'all' },
|
||||||
{ label: 'Admin Actions', val: 'admin_action' },
|
{ label: 'Admin Actions', val: 'admin_action' },
|
||||||
{ label: 'OCR Checks', val: 'verification' },
|
{ label: 'Verifications', val: 'verification' },
|
||||||
{ label: 'Payments', val: 'payment' },
|
{ label: 'Payments', val: 'payment' },
|
||||||
{ label: 'Disputes', val: 'dispute' },
|
{ label: 'Disputes', val: 'dispute' },
|
||||||
{ label: 'User Logins', val: 'user_activity' }
|
{ label: 'Logins', val: 'user_activity' }
|
||||||
].map((cat) => (
|
].map((cat) => (
|
||||||
<button
|
<button
|
||||||
key={cat.val}
|
key={cat.val}
|
||||||
@ -108,18 +100,18 @@ export default function AuditLogsHub({ logs, search, category }) {
|
|||||||
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden">
|
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden">
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow className="bg-slate-50/50 hover:bg-slate-50/50">
|
<TableRow className="bg-slate-50/50 hover:bg-slate-50/50 border-b border-slate-150">
|
||||||
<TableHead className="font-bold text-slate-500 text-[10px] uppercase tracking-widest h-14 pl-8">Event ID / Category</TableHead>
|
<TableHead className="font-bold text-slate-550 text-[10px] uppercase tracking-widest h-14 pl-8">Log ID & Category</TableHead>
|
||||||
<TableHead className="font-bold text-slate-500 text-[10px] uppercase tracking-widest h-14">Actor Account</TableHead>
|
<TableHead className="font-bold text-slate-550 text-[10px] uppercase tracking-widest h-14">User</TableHead>
|
||||||
<TableHead className="font-bold text-slate-500 text-[10px] uppercase tracking-widest h-14">Activity Description</TableHead>
|
<TableHead className="font-bold text-slate-550 text-[10px] uppercase tracking-widest h-14">Action</TableHead>
|
||||||
<TableHead className="font-bold text-slate-500 text-[10px] uppercase tracking-widest h-14">IP Address</TableHead>
|
<TableHead className="font-bold text-slate-550 text-[10px] uppercase tracking-widest h-14">IP Address</TableHead>
|
||||||
<TableHead className="font-bold text-slate-500 text-[10px] uppercase tracking-widest h-14 text-right pr-8">Timestamp</TableHead>
|
<TableHead className="font-bold text-slate-550 text-[10px] uppercase tracking-widest h-14 text-right pr-8">Time</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{logs && logs.length > 0 ? (
|
{logs && logs.length > 0 ? (
|
||||||
logs.map((log) => (
|
logs.map((log) => (
|
||||||
<TableRow key={log.id} className="hover:bg-slate-50/50 transition-colors">
|
<TableRow key={log.id} className="hover:bg-slate-50/50 transition-colors border-b border-slate-100">
|
||||||
<TableCell className="py-4 pl-8">
|
<TableCell className="py-4 pl-8">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Badge className={`border-none text-[8px] font-black uppercase tracking-wider ${
|
<Badge className={`border-none text-[8px] font-black uppercase tracking-wider ${
|
||||||
@ -137,8 +129,10 @@ export default function AuditLogsHub({ logs, search, category }) {
|
|||||||
<span>{log.user}</span>
|
<span>{log.user}</span>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-xs font-semibold text-slate-600 leading-normal max-w-sm">
|
<TableCell className="text-xs font-semibold text-slate-600 leading-normal">
|
||||||
|
<div className="max-w-[420px] break-words whitespace-normal py-1">
|
||||||
{log.action}
|
{log.action}
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-xs font-mono font-medium text-slate-400">
|
<TableCell className="text-xs font-mono font-medium text-slate-400">
|
||||||
<div className="flex items-center space-x-1">
|
<div className="flex items-center space-x-1">
|
||||||
@ -146,14 +140,14 @@ export default function AuditLogsHub({ logs, search, category }) {
|
|||||||
<span>{log.ip_address}</span>
|
<span>{log.ip_address}</span>
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-xs text-slate-400 text-right pr-8 font-mono">{log.timestamp}</TableCell>
|
<TableCell className="text-xs text-slate-400 text-right pr-8 font-semibold">{log.timestamp}</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))
|
))
|
||||||
) : (
|
) : (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={5} className="py-20 text-center">
|
<TableCell colSpan={5} className="py-20 text-center">
|
||||||
<Terminal className="w-12 h-12 text-slate-200 mx-auto mb-3" />
|
<Terminal className="w-12 h-12 text-slate-200 mx-auto mb-3" />
|
||||||
<div className="font-bold text-slate-400">No security audit logs found matching your criteria.</div>
|
<div className="font-bold text-slate-400 uppercase text-xs">No logs found.</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -13,119 +13,401 @@ import {
|
|||||||
ArrowUpRight,
|
ArrowUpRight,
|
||||||
User,
|
User,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Send
|
Send,
|
||||||
|
Search,
|
||||||
|
ChevronDown,
|
||||||
|
MoreVertical,
|
||||||
|
Clock,
|
||||||
|
Eye,
|
||||||
|
Plus,
|
||||||
|
X,
|
||||||
|
Folder,
|
||||||
|
AlertTriangle,
|
||||||
|
Hourglass
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
|
||||||
export default function DisputesHub({ tickets }) {
|
export default function DisputesHub({ tickets }) {
|
||||||
const [selectedTicket, setSelectedTicket] = useState(tickets[0] || null);
|
const [selectedTicket, setSelectedTicket] = useState(null);
|
||||||
|
const [isWorkspaceOpen, setIsWorkspaceOpen] = useState(false);
|
||||||
|
|
||||||
// Notes / Resolution states
|
// Notes / Resolution states
|
||||||
const [internalNote, setInternalNote] = useState('');
|
const [internalNote, setInternalNote] = useState('');
|
||||||
const [resolutionAction, setResolutionAction] = useState('refund_employer');
|
const [resolutionAction, setResolutionAction] = useState('refund_employer');
|
||||||
const [resolutionSummary, setResolutionSummary] = useState('');
|
const [resolutionSummary, setResolutionSummary] = useState('');
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [statusFilter, setStatusFilter] = useState('All Status');
|
||||||
|
const [priorityFilter, setPriorityFilter] = useState('All Priority');
|
||||||
|
const [typeFilter, setTypeFilter] = useState('All Types');
|
||||||
|
const [sortBy, setSortBy] = useState('Newest First');
|
||||||
|
|
||||||
const handleAddNote = (e) => {
|
const handleAddNote = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!internalNote.trim()) return;
|
if (!internalNote.trim() || !selectedTicket) return;
|
||||||
|
|
||||||
router.post(`/admin/disputes/${selectedTicket.id}/add-note`, { note: internalNote }, {
|
router.post(`/admin/disputes/${selectedTicket.id}/add-note`, { note: internalNote }, {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
if (selectedTicket) {
|
|
||||||
selectedTicket.admin_notes += "\n• " + internalNote;
|
selectedTicket.admin_notes += "\n• " + internalNote;
|
||||||
}
|
|
||||||
setInternalNote('');
|
setInternalNote('');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleResolve = (e) => {
|
const handleResolve = (e) => {
|
||||||
e.preventDefault();
|
if (e) e.preventDefault();
|
||||||
|
if (!selectedTicket) return;
|
||||||
|
|
||||||
router.post(`/admin/disputes/${selectedTicket.id}/resolve`, {
|
router.post(`/admin/disputes/${selectedTicket.id}/resolve`, {
|
||||||
resolution: resolutionSummary,
|
resolution: internalNote || 'Resolved by mediator',
|
||||||
action_taken: resolutionAction
|
action_taken: 'close_ticket'
|
||||||
}, {
|
}, {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
if (selectedTicket) {
|
|
||||||
selectedTicket.status = 'Resolved';
|
selectedTicket.status = 'Resolved';
|
||||||
}
|
setIsWorkspaceOpen(false);
|
||||||
|
setSelectedTicket(null);
|
||||||
|
setInternalNote('');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
// Filter tickets
|
||||||
<AdminLayout title="Disputes Management Hub">
|
const filteredTickets = (tickets || []).filter(ticket => {
|
||||||
<Head title="Disputes Management" />
|
// Status filter
|
||||||
|
if (statusFilter !== 'All Status' && ticket.status !== statusFilter) return false;
|
||||||
|
|
||||||
<div className="font-sans max-w-7xl mx-auto space-y-6">
|
// Priority filter
|
||||||
|
if (priorityFilter !== 'All Priority' && ticket.priority !== priorityFilter) return false;
|
||||||
|
|
||||||
|
// Type filter
|
||||||
|
if (typeFilter !== 'All Types' && ticket.type !== typeFilter) return false;
|
||||||
|
|
||||||
|
// Search query
|
||||||
|
if (searchQuery.trim() !== '') {
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
return (
|
||||||
|
ticket.id.toLowerCase().includes(query) ||
|
||||||
|
ticket.party_one_name.toLowerCase().includes(query) ||
|
||||||
|
ticket.party_two_name.toLowerCase().includes(query) ||
|
||||||
|
ticket.reason.toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AdminLayout title="Dispute Management">
|
||||||
|
<Head title="Dispute Management" />
|
||||||
|
|
||||||
|
<div className="font-sans max-w-[1600px] mx-auto space-y-6">
|
||||||
|
|
||||||
{/* Header Row */}
|
{/* Header Row */}
|
||||||
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-bold text-gray-900 tracking-tight">Contract & Payment Dispute Resolution Center</h1>
|
<h1 className="text-2xl font-bold text-slate-900 tracking-tight">Dispute Management</h1>
|
||||||
<p className="text-xs text-gray-500 mt-0.5">Audit employer-worker chat history transcripts, review visa fee dispute receipts, and settle escrow refund transfers.</p>
|
<p className="text-sm text-slate-500 mt-1">Handle complaints and resolve disputes between workers and sponsors.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 items-start">
|
<button className="inline-flex items-center gap-1.5 px-4 py-2.5 bg-[#0F6E56] hover:bg-[#085041] text-white rounded-xl text-xs font-bold transition-all shadow-md self-end md:self-auto uppercase tracking-wider">
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
<span>New Dispute</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Column 1: Disputes List */}
|
{/* Upper Row Metric Cards */}
|
||||||
<div className="space-y-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
<div className="bg-white p-4 border border-slate-200 rounded-2xl shadow-sm">
|
|
||||||
<span className="text-[9px] font-black text-slate-400 uppercase tracking-widest block mb-3">Open Dispute Tickets</span>
|
{/* Card 1: Total Disputes */}
|
||||||
<div className="space-y-2">
|
<div className="bg-white p-5 border border-slate-200 rounded-2xl shadow-sm flex items-center justify-between">
|
||||||
{tickets.map((ticket) => (
|
<div className="space-y-1">
|
||||||
<div
|
<span className="text-xs text-slate-500 font-semibold block">Total Disputes</span>
|
||||||
key={ticket.id}
|
<span className="text-2xl font-extrabold text-slate-900 block">36</span>
|
||||||
onClick={() => setSelectedTicket(ticket)}
|
<span className="inline-flex items-center gap-0.5 px-2 py-0.5 bg-emerald-50 text-emerald-700 rounded-full text-[10px] font-bold">
|
||||||
className={`p-4 rounded-xl border cursor-pointer transition-all ${
|
↑ 8% <span className="font-normal text-slate-400 ml-1">from last 30 days</span>
|
||||||
selectedTicket?.id === ticket.id
|
</span>
|
||||||
? 'bg-emerald-50/50 border-[#0F6E56] shadow-sm'
|
</div>
|
||||||
: 'bg-white border-slate-100 hover:bg-slate-50'
|
<div className="p-3 bg-[#0F6E56]/10 text-[#0F6E56] rounded-2xl">
|
||||||
}`}
|
<Scale className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card 2: Open */}
|
||||||
|
<div className="bg-white p-5 border border-slate-200 rounded-2xl shadow-sm flex items-center justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className="text-xs text-slate-500 font-semibold block">Open</span>
|
||||||
|
<span className="text-2xl font-extrabold text-slate-900 block">12</span>
|
||||||
|
<span className="text-[11px] text-amber-600 font-bold block">Needs attention</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-amber-50 text-amber-600 rounded-2xl">
|
||||||
|
<Folder className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card 3: Under Review */}
|
||||||
|
<div className="bg-white p-5 border border-slate-200 rounded-2xl shadow-sm flex items-center justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className="text-xs text-slate-500 font-semibold block">Under Review</span>
|
||||||
|
<span className="text-2xl font-extrabold text-slate-900 block">10</span>
|
||||||
|
<span className="text-[11px] text-blue-600 font-bold block">In progress</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-blue-50 text-blue-600 rounded-2xl">
|
||||||
|
<Eye className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card 4: Resolved */}
|
||||||
|
<div className="bg-white p-5 border border-slate-200 rounded-2xl shadow-sm flex items-center justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className="text-xs text-slate-500 font-semibold block">Resolved</span>
|
||||||
|
<span className="text-2xl font-extrabold text-slate-900 block">8</span>
|
||||||
|
<span className="text-[11px] text-emerald-600 font-bold block">This period</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-emerald-50 text-emerald-600 rounded-2xl">
|
||||||
|
<CheckCircle className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table / Filters Section */}
|
||||||
|
<div className="bg-white border border-slate-200 rounded-2xl shadow-sm overflow-hidden">
|
||||||
|
|
||||||
|
{/* Filters Header */}
|
||||||
|
<div className="p-4 border-b border-slate-100 flex flex-col lg:flex-row lg:items-center justify-between gap-4 bg-slate-50/20">
|
||||||
|
{/* Left search */}
|
||||||
|
<div className="relative w-full lg:w-80">
|
||||||
|
<Search className="w-4 h-4 text-slate-400 absolute left-3.5 top-1/2 -translate-y-1/2" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search disputes..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={e => setSearchQuery(e.target.value)}
|
||||||
|
className="bg-white border border-slate-200 pl-10 pr-4 py-2 rounded-xl text-xs font-semibold focus:outline-none focus:ring-2 focus:ring-[#0F6E56]/20 w-full shadow-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right filters */}
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={e => setStatusFilter(e.target.value)}
|
||||||
|
className="bg-white border border-slate-200 text-slate-700 text-xs font-bold px-3 py-2 rounded-xl outline-none cursor-pointer hover:bg-slate-50 transition-colors shadow-sm"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between mb-2">
|
<option value="All Status">All Status</option>
|
||||||
<span className="font-mono text-[9px] text-slate-400 font-bold">{ticket.id}</span>
|
<option value="Open">Open</option>
|
||||||
<Badge className={`border-none text-[8px] font-black uppercase ${
|
<option value="Under Review">Under Review</option>
|
||||||
ticket.status === 'Open' ? 'bg-amber-100 text-amber-700' :
|
<option value="Waiting Response">Waiting Response</option>
|
||||||
ticket.status === 'Resolved' ? 'bg-emerald-100 text-emerald-800' : 'bg-blue-100 text-blue-700'
|
<option value="Resolved">Resolved</option>
|
||||||
}`}>{ticket.status}</Badge>
|
</select>
|
||||||
</div>
|
|
||||||
<h4 className="text-xs font-black text-slate-900 tracking-tight leading-tight">{ticket.subject}</h4>
|
|
||||||
<div className="flex items-center justify-between mt-3 text-[10px] text-slate-400 font-bold">
|
|
||||||
<span>AED {ticket.amount_aed}</span>
|
<select
|
||||||
<span>{ticket.created_at}</span>
|
value={typeFilter}
|
||||||
</div>
|
onChange={e => setTypeFilter(e.target.value)}
|
||||||
</div>
|
className="bg-white border border-slate-200 text-slate-700 text-xs font-bold px-3 py-2 rounded-xl outline-none cursor-pointer hover:bg-slate-50 transition-colors shadow-sm"
|
||||||
))}
|
>
|
||||||
</div>
|
<option value="All Types">All Types</option>
|
||||||
|
<option value="Payment Issue">Payment Issue</option>
|
||||||
|
<option value="Behavior Issue">Behavior Issue</option>
|
||||||
|
<option value="Working Conditions">Working Conditions</option>
|
||||||
|
<option value="Abuse/Harassment">Abuse/Harassment</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select
|
||||||
|
value={sortBy}
|
||||||
|
onChange={e => setSortBy(e.target.value)}
|
||||||
|
className="bg-white border border-slate-200 text-slate-700 text-xs font-bold px-3 py-2 rounded-xl outline-none cursor-pointer hover:bg-slate-50 transition-colors shadow-sm"
|
||||||
|
>
|
||||||
|
<option value="Newest First">Sort: Newest First</option>
|
||||||
|
<option value="Oldest First">Sort: Oldest First</option>
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Column 2: Audit Logs & Chat Logs */}
|
{/* Table Row Content */}
|
||||||
<div className="lg:col-span-2 space-y-6">
|
<div className="overflow-x-auto">
|
||||||
{selectedTicket ? (
|
<table className="w-full text-left border-collapse text-xs font-semibold text-slate-600">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<thead>
|
||||||
|
<tr className="border-b border-slate-100 bg-slate-50/50 text-slate-400 font-bold uppercase tracking-wider text-[10px]">
|
||||||
{/* Chat Log Audit */}
|
<th className="px-5 py-4">Dispute ID</th>
|
||||||
<div className="bg-white border border-slate-200 rounded-3xl p-5 shadow-sm flex flex-col justify-between h-[520px]">
|
<th className="px-5 py-4">Raised By</th>
|
||||||
|
<th className="px-5 py-4">Type</th>
|
||||||
|
<th className="px-5 py-4">Reason</th>
|
||||||
|
<th className="px-5 py-4">Status</th>
|
||||||
|
<th className="px-5 py-4">Created At</th>
|
||||||
|
<th className="px-5 py-4 text-right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-100">
|
||||||
|
{filteredTickets.map((ticket) => (
|
||||||
|
<tr key={ticket.id} className="hover:bg-slate-50/40 transition-colors">
|
||||||
|
<td className="px-5 py-4 font-mono font-bold text-[#0F6E56] hover:underline cursor-pointer">
|
||||||
|
{ticket.id}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<img
|
||||||
|
src={ticket.party_one_avatar || 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&q=80&w=100'}
|
||||||
|
alt={ticket.party_one_name}
|
||||||
|
className="w-7 h-7 rounded-full object-cover border border-slate-200 shadow-sm"
|
||||||
|
/>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-xs font-black text-slate-800 uppercase tracking-widest flex items-center gap-1">
|
<span className="font-extrabold text-slate-800 block">
|
||||||
<MessageSquare className="w-4 h-4 text-[#0F6E56]" />
|
{ticket.party_one_name}
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] text-slate-400 font-bold block uppercase tracking-wider">
|
||||||
|
{ticket.party_one_role}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-4">
|
||||||
|
<span className={`px-2.5 py-1 rounded-full text-[10px] font-black uppercase ${
|
||||||
|
ticket.type === 'Payment Issue' ? 'bg-blue-100 text-blue-700' :
|
||||||
|
ticket.type === 'Behavior Issue' ? 'bg-purple-100 text-purple-700' :
|
||||||
|
ticket.type === 'Working Conditions' ? 'bg-emerald-100 text-emerald-700' : 'bg-red-100 text-red-700'
|
||||||
|
}`}>
|
||||||
|
{ticket.type}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-4 font-semibold text-slate-800">
|
||||||
|
{ticket.reason}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-4">
|
||||||
|
<span className={`px-2 py-0.5 rounded-full text-[10px] font-black uppercase ${
|
||||||
|
ticket.status === 'Open' ? 'bg-orange-50 text-orange-700' :
|
||||||
|
ticket.status === 'Under Review' ? 'bg-blue-50 text-blue-700' :
|
||||||
|
ticket.status === 'Waiting Response' ? 'bg-purple-50 text-purple-700' : 'bg-emerald-50 text-emerald-700'
|
||||||
|
}`}>
|
||||||
|
{ticket.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-4 text-slate-400 font-semibold">
|
||||||
|
{ticket.created_at}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-4 text-right">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => { setSelectedTicket(ticket); setIsWorkspaceOpen(true); }}
|
||||||
|
className="px-3 py-1.5 bg-white border border-slate-200 text-slate-700 rounded-lg hover:bg-slate-50 transition-colors shadow-sm font-bold uppercase tracking-wider text-[10px]"
|
||||||
|
>
|
||||||
|
View
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setSelectedTicket(ticket); setIsWorkspaceOpen(true); }}
|
||||||
|
className="px-3.5 py-1.5 bg-[#0F6E56] hover:bg-[#085041] text-white rounded-lg transition-colors font-black uppercase tracking-wider text-[10px]"
|
||||||
|
>
|
||||||
|
Manage
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{filteredTickets.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan="9" className="p-10 text-center font-bold text-slate-400 uppercase">
|
||||||
|
No active disputes found matching your criteria.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Premium Full Detailed Investigation Workspace Sidebar Drawer */}
|
||||||
|
{isWorkspaceOpen && selectedTicket && (
|
||||||
|
<div className="fixed inset-0 bg-slate-900/50 backdrop-blur-sm z-50 flex justify-end transition-opacity">
|
||||||
|
<div className="w-full max-w-5xl bg-slate-50 h-full shadow-2xl flex flex-col justify-between overflow-hidden animate-slide-in-right">
|
||||||
|
|
||||||
|
{/* Drawer Header */}
|
||||||
|
<div className="bg-white p-5 border-b border-slate-200 flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Scale className="w-6 h-6 text-[#0F6E56]" />
|
||||||
|
<div>
|
||||||
|
<h2 className="text-base font-black text-slate-800 uppercase tracking-wide flex items-center gap-1.5">
|
||||||
|
<span>Dispute Resolution Case:</span>
|
||||||
|
<span className="font-mono text-[#0F6E56]">{selectedTicket.id}</span>
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-slate-400 font-semibold mt-0.5">Assigned Mediator: {selectedTicket.assigned_to}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => { setIsWorkspaceOpen(false); setSelectedTicket(null); }}
|
||||||
|
className="p-2 text-slate-400 hover:text-slate-600 hover:bg-slate-100 rounded-full transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Drawer Body Scroll Container */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-6 space-y-6">
|
||||||
|
|
||||||
|
{/* Case Header Details Banner */}
|
||||||
|
<div className="bg-white border border-slate-200 rounded-2xl p-5 shadow-sm grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className="text-[10px] text-slate-400 font-bold uppercase block">Parties</span>
|
||||||
|
<span className="font-extrabold text-slate-800 text-sm block">
|
||||||
|
{selectedTicket.party_one_name} <span className="font-normal text-slate-400">({selectedTicket.party_one_role})</span>
|
||||||
|
</span>
|
||||||
|
<span className="font-semibold text-slate-500 text-xs block">
|
||||||
|
vs {selectedTicket.party_two_name} <span className="font-normal text-slate-400">({selectedTicket.party_two_role})</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className="text-[10px] text-slate-400 font-bold uppercase block">Dispute Type</span>
|
||||||
|
<span className="font-extrabold text-slate-800 block text-xs uppercase tracking-wide">{selectedTicket.type}</span>
|
||||||
|
<span className="text-[11px] text-slate-500 font-medium block leading-relaxed">{selectedTicket.reason}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className="text-[10px] text-slate-400 font-bold uppercase block">Case Status</span>
|
||||||
|
<span className={`block px-2.5 py-0.5 rounded-full text-[10px] font-black uppercase w-fit ${
|
||||||
|
selectedTicket.status === 'Resolved' ? 'bg-emerald-50 text-emerald-700' : 'bg-orange-50 text-orange-700'
|
||||||
|
}`}>
|
||||||
|
Status: {selectedTicket.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className="text-[10px] text-slate-400 font-bold uppercase block">Created Date</span>
|
||||||
|
<span className="font-extrabold text-slate-800 block">{selectedTicket.created_at}</span>
|
||||||
|
<span className="text-[10px] text-slate-400 font-semibold block">Case active since last update</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dual Panel Workspace */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
|
||||||
|
{/* Chat Log Transcript */}
|
||||||
|
<div className="bg-white border border-slate-200 rounded-2xl p-5 shadow-sm flex flex-col h-[480px]">
|
||||||
|
<div className="border-b pb-3">
|
||||||
|
<h3 className="text-xs font-black text-slate-800 uppercase tracking-widest flex items-center gap-1.5">
|
||||||
|
<MessageSquare className="w-4.5 h-4.5 text-[#0F6E56]" />
|
||||||
<span>Direct Chat logs Transcript</span>
|
<span>Direct Chat logs Transcript</span>
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-[9px] text-slate-400 font-semibold mt-1">Official authenticated conversation audit log</p>
|
<p className="text-[10px] text-slate-400 font-semibold mt-1">Official authenticated conversation history audits</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Conversation thread */}
|
{/* Scroll thread */}
|
||||||
<div className="flex-1 overflow-y-auto space-y-3 my-4 pr-2 max-h-[360px]">
|
<div className="flex-1 overflow-y-auto space-y-4 my-4 pr-1 scrollbar-thin">
|
||||||
{selectedTicket.chat_logs.map((log, i) => {
|
{selectedTicket.chat_logs.map((log, i) => {
|
||||||
const isEmployer = log.sender === 'Employer';
|
const isMediator = log.sender === 'Mediator (Admin)';
|
||||||
|
const isPartyTwo = log.sender === selectedTicket.party_two_name;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={i}
|
key={i}
|
||||||
className={`max-w-[85%] p-3 rounded-2xl text-[11px] font-bold shadow-sm ${
|
className={`max-w-[90%] p-3 rounded-2xl text-[11px] font-bold shadow-sm ${
|
||||||
isEmployer
|
isMediator
|
||||||
|
? 'bg-emerald-50 text-emerald-950 border border-emerald-250 mx-auto'
|
||||||
|
: isPartyTwo
|
||||||
? 'bg-blue-50 text-blue-900 mr-auto border border-blue-100'
|
? 'bg-blue-50 text-blue-900 mr-auto border border-blue-100'
|
||||||
: 'bg-teal-50 text-teal-900 ml-auto border border-teal-100'
|
: 'bg-indigo-50 text-indigo-900 ml-auto border border-indigo-100'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span className="text-[8px] font-black uppercase text-slate-400 block mb-1">{log.sender} • {log.time}</span>
|
<span className="text-[8px] font-black uppercase text-slate-400 block mb-1">{log.sender} • {log.time}</span>
|
||||||
@ -135,101 +417,80 @@ export default function DisputesHub({ tickets }) {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="text-[10px] bg-slate-50 p-2.5 rounded-xl border border-slate-100 text-slate-400 font-semibold text-center uppercase tracking-wide">
|
<div className="text-[10px] bg-slate-50 p-2.5 rounded-xl border border-slate-100 text-slate-400 font-bold text-center uppercase tracking-wider">
|
||||||
Logs Digitally Secured by UAE Vetting Compliance
|
Logs Digitally Secured by UAE Vetting Compliance
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Evidence & Resolution Tools */}
|
{/* Tools panel */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
|
||||||
{/* Evidence Files */}
|
{/* Evidence Files */}
|
||||||
<div className="bg-white border border-slate-200 rounded-3xl p-5 shadow-sm space-y-3">
|
<div className="bg-white border border-slate-200 rounded-2xl p-5 shadow-sm space-y-3">
|
||||||
<h3 className="text-xs font-black text-slate-800 uppercase tracking-widest flex items-center gap-1">
|
<h3 className="text-xs font-black text-slate-800 uppercase tracking-widest flex items-center gap-1.5">
|
||||||
<FileText className="w-4 h-4 text-[#0F6E56]" />
|
<FileText className="w-4.5 h-4.5 text-[#0F6E56]" />
|
||||||
<span>Evidence Files</span>
|
<span>Case Evidence Files</span>
|
||||||
</h3>
|
</h3>
|
||||||
{selectedTicket.evidence?.length > 0 ? (
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{selectedTicket.evidence.map((file, i) => (
|
{selectedTicket.evidence?.map((file, i) => (
|
||||||
<div key={i} className="flex items-center justify-between p-2.5 bg-slate-50 rounded-xl border text-xs font-bold text-slate-700">
|
<div key={i} className="flex items-center justify-between p-2.5 bg-slate-50 rounded-xl border border-slate-150 text-xs font-bold text-slate-700 shadow-sm hover:bg-slate-100 transition-colors">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FileText className="w-4 h-4 text-slate-400" />
|
||||||
<span>{file.name} ({file.type})</span>
|
<span>{file.name} ({file.type})</span>
|
||||||
|
</div>
|
||||||
<a href={file.url} className="text-[#0F6E56] hover:underline flex items-center gap-0.5">
|
<a href={file.url} className="text-[#0F6E56] hover:underline flex items-center gap-0.5">
|
||||||
<span>View File</span>
|
<span>View</span>
|
||||||
<ArrowUpRight className="w-3.5 h-3.5" />
|
<ArrowUpRight className="w-3.5 h-3.5" />
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<p className="text-[10px] text-slate-400 font-semibold uppercase">No attachment proofs uploaded.</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Admin Notes */}
|
{/* Simplified Action Panel */}
|
||||||
<div className="bg-yellow-50/50 border border-yellow-200 rounded-3xl p-5 shadow-sm space-y-3">
|
<div className="bg-white border border-slate-200 rounded-2xl p-5 shadow-sm space-y-4">
|
||||||
<h3 className="text-xs font-black text-yellow-800 uppercase tracking-widest">Internal Compliance Notes</h3>
|
<div>
|
||||||
<p className="text-[11px] text-slate-600 bg-white p-3 rounded-xl border border-yellow-100 font-semibold leading-relaxed whitespace-pre-line">
|
<h3 className="text-xs font-black text-slate-800 uppercase tracking-widest flex items-center gap-2">
|
||||||
{selectedTicket.admin_notes}
|
<MessageSquare className="w-4 h-4 text-[#0F6E56]" />
|
||||||
</p>
|
<span>Mediator Reply & Actions</span>
|
||||||
<form onSubmit={handleAddNote} className="flex gap-2">
|
</h3>
|
||||||
<input
|
<p className="text-[10px] text-slate-400 font-semibold mt-1">Send a message to both parties or resolve the dispute.</p>
|
||||||
type="text"
|
</div>
|
||||||
className="flex-1 bg-white border border-slate-200 rounded-xl px-3 py-2 text-xs font-bold focus:ring-2 focus:ring-yellow-500/10 outline-none"
|
|
||||||
placeholder="Write compliance log note..."
|
<form onSubmit={handleAddNote} className="space-y-3">
|
||||||
|
<textarea
|
||||||
|
rows="3"
|
||||||
|
className="w-full bg-white border border-slate-200 rounded-xl p-3 text-xs font-semibold focus:ring-2 focus:ring-[#0F6E56]/10 outline-none"
|
||||||
|
placeholder="Write your response or internal compliance update..."
|
||||||
value={internalNote}
|
value={internalNote}
|
||||||
onChange={e => setInternalNote(e.target.value)}
|
onChange={e => setInternalNote(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<button type="submit" className="p-2 bg-[#0F6E56] text-white rounded-xl">
|
<div className="flex gap-2">
|
||||||
<Send className="w-4.5 h-4.5" />
|
<button
|
||||||
</button>
|
type="submit"
|
||||||
</form>
|
className="flex-1 py-2.5 bg-[#0F6E56] hover:bg-[#085041] text-white rounded-xl text-[10px] font-black uppercase tracking-wider transition-colors shadow-sm"
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Resolution Form */}
|
|
||||||
{selectedTicket.status !== 'Resolved' && (
|
|
||||||
<div className="bg-white border border-slate-200 rounded-3xl p-5 shadow-sm space-y-4">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-xs font-black text-slate-800 uppercase tracking-widest">Settle Ticket Resolution</h3>
|
|
||||||
<p className="text-[10px] text-slate-400 font-semibold mt-1">Submit official dispute settlement outcome</p>
|
|
||||||
</div>
|
|
||||||
<form onSubmit={handleResolve} className="space-y-3 text-xs font-bold text-slate-700">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="text-[9px] font-black text-slate-400 uppercase tracking-widest">Resolution Verdict</label>
|
|
||||||
<select
|
|
||||||
className="w-full bg-slate-50 border rounded-xl px-3 py-2.5 outline-none cursor-pointer focus:bg-white"
|
|
||||||
value={resolutionAction}
|
|
||||||
onChange={e => setResolutionAction(e.target.value)}
|
|
||||||
>
|
>
|
||||||
<option value="refund_employer">Approve Full Refund to Employer (AED {selectedTicket.amount_aed})</option>
|
Send Reply
|
||||||
<option value="partial_refund">Approve 50% split partial refund</option>
|
|
||||||
<option value="payout_worker">Dismiss dispute & release payout to worker</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1">
|
|
||||||
<textarea
|
|
||||||
rows="2"
|
|
||||||
placeholder="Provide brief explanation for payment release/refund decisions..."
|
|
||||||
className="w-full bg-white border rounded-xl p-3 font-semibold focus:ring-2 focus:ring-[#0F6E56]/10 outline-none"
|
|
||||||
value={resolutionSummary}
|
|
||||||
onChange={e => setResolutionSummary(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button type="submit" className="w-full py-2.5 bg-slate-900 hover:bg-slate-800 text-white rounded-xl text-[10px] font-black uppercase tracking-widest shadow-md">
|
|
||||||
Submit Official Verdict
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{selectedTicket.status !== 'Resolved' && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleResolve}
|
||||||
|
className="px-4 py-2.5 bg-red-600 hover:bg-red-700 text-white rounded-xl text-[10px] font-black uppercase tracking-wider transition-colors shadow-sm"
|
||||||
|
>
|
||||||
|
Close Ticket
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
</div>
|
||||||
<div className="bg-white rounded-3xl border p-20 text-center text-slate-400 font-bold">
|
</div>
|
||||||
Select a dispute ticket from the left panel.
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</AdminLayout>
|
</AdminLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,21 +2,13 @@ import React, { useState } from 'react';
|
|||||||
import { Head, router } from '@inertiajs/react';
|
import { Head, router } from '@inertiajs/react';
|
||||||
import AdminLayout from '@/Layouts/AdminLayout';
|
import AdminLayout from '@/Layouts/AdminLayout';
|
||||||
import {
|
import {
|
||||||
BellRing,
|
|
||||||
Send,
|
Send,
|
||||||
Smartphone,
|
|
||||||
MessageCircle,
|
|
||||||
CheckCircle,
|
|
||||||
BarChart3,
|
|
||||||
Users,
|
|
||||||
AlertTriangle,
|
|
||||||
History,
|
History,
|
||||||
Sparkles,
|
Sparkles
|
||||||
TrendingUp
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
|
||||||
export default function NotificationsCampaigns({ campaigns, whatsapp_templates }) {
|
export default function NotificationsCampaigns({ campaigns = [] }) {
|
||||||
const [channel, setChannel] = useState('push');
|
const [channel, setChannel] = useState('push');
|
||||||
const [recipients, setRecipients] = useState('All Active Workers');
|
const [recipients, setRecipients] = useState('All Active Workers');
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState('');
|
||||||
@ -38,83 +30,77 @@ export default function NotificationsCampaigns({ campaigns, whatsapp_templates }
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminLayout title="Campaigns & Notifications Hub">
|
<AdminLayout title="Notifications">
|
||||||
<Head title="Campaigns & Alerts" />
|
<Head title="Notifications" />
|
||||||
|
|
||||||
<div className="font-sans max-w-7xl mx-auto space-y-8">
|
<div className="font-sans max-w-4xl mx-auto space-y-8 pb-12">
|
||||||
|
|
||||||
{/* Header overview and status */}
|
{/* Simplified Header */}
|
||||||
<div>
|
<div className="border-b border-slate-200 pb-5">
|
||||||
<h1 className="text-xl font-bold text-gray-900 tracking-tight">System Campaign Broadcasting Center</h1>
|
<h1 className="text-xl font-black text-slate-800 uppercase tracking-tight">Send Notification</h1>
|
||||||
<p className="text-xs text-gray-500 mt-0.5">Reach users directly via SMS, WhatsApp, and Native Push notifications. Monitor open rates and clicks.</p>
|
<p className="text-xs text-slate-500 mt-0.5 font-medium">Send quick push or WhatsApp alerts to workers and sponsors.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 items-start">
|
<div className="grid grid-cols-1 gap-8">
|
||||||
|
|
||||||
{/* Column 1 & 2: Broadcast Composer & Campaigns History */}
|
{/* Message Composer */}
|
||||||
<div className="lg:col-span-2 space-y-8">
|
|
||||||
|
|
||||||
{/* Section 1: Composer */}
|
|
||||||
<div className="bg-white border border-slate-200 rounded-3xl p-6 shadow-sm">
|
<div className="bg-white border border-slate-200 rounded-3xl p-6 shadow-sm">
|
||||||
<div className="mb-6 flex items-center justify-between">
|
<div className="mb-6 flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-black text-slate-800 uppercase tracking-widest flex items-center gap-1">
|
<h3 className="text-sm font-black text-slate-800 uppercase tracking-widest flex items-center gap-1.5">
|
||||||
<Sparkles className="w-4 h-4 text-[#0F6E56]" />
|
<Sparkles className="w-4 h-4 text-[#0F6E56]" />
|
||||||
<span>Direct Campaign Composer</span>
|
<span>New Notification Alert</span>
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-[10px] text-slate-400 font-semibold mt-1">Schedules immediate or recurring broadcasts</p>
|
<p className="text-[10px] text-slate-400 font-semibold mt-1">Send a dynamic message immediately</p>
|
||||||
</div>
|
</div>
|
||||||
<Badge className="bg-emerald-50 text-emerald-700 border-none font-bold uppercase text-[8px] tracking-wider">SMS Gateway: Active</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleBroadcast} className="space-y-4 text-xs font-bold text-slate-700">
|
<form onSubmit={handleBroadcast} className="space-y-4 text-xs font-bold text-slate-700">
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Select Channel</label>
|
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Message Channel</label>
|
||||||
<select
|
<select
|
||||||
className="w-full bg-slate-50 border rounded-xl px-3 py-2.5 outline-none cursor-pointer focus:bg-white"
|
className="w-full bg-slate-50 border rounded-xl px-3 py-2.5 outline-none cursor-pointer focus:bg-white"
|
||||||
value={channel}
|
value={channel}
|
||||||
onChange={e => setChannel(e.target.value)}
|
onChange={e => setChannel(e.target.value)}
|
||||||
>
|
>
|
||||||
<option value="push">Native App Push Notification</option>
|
<option value="push">App Message</option>
|
||||||
<option value="whatsapp">WhatsApp Business API</option>
|
<option value="whatsapp">WhatsApp Message</option>
|
||||||
<option value="both">Both (Multi-channel coverage)</option>
|
<option value="both">Both Channels</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Target User Cohort</label>
|
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Send To</label>
|
||||||
<select
|
<select
|
||||||
className="w-full bg-slate-50 border rounded-xl px-3 py-2.5 outline-none cursor-pointer focus:bg-white"
|
className="w-full bg-slate-50 border rounded-xl px-3 py-2.5 outline-none cursor-pointer focus:bg-white"
|
||||||
value={recipients}
|
value={recipients}
|
||||||
onChange={e => setRecipients(e.target.value)}
|
onChange={e => setRecipients(e.target.value)}
|
||||||
>
|
>
|
||||||
<option value="All Active Workers">All Active Workers (1,250 users)</option>
|
<option value="All Active Workers">All Workers</option>
|
||||||
<option value="Unverified Workers (Morocco & Philippines)">Unverified Workers Pool (142 users)</option>
|
<option value="All Employers">All Sponsors</option>
|
||||||
<option value="Premium Employers">Premium Employers only (312 users)</option>
|
|
||||||
<option value="All Employers">All Registered Employers (380 users)</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Notification Title</label>
|
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Message Title</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
required
|
required
|
||||||
className="w-full bg-slate-50 border rounded-xl px-3 py-2.5 outline-none focus:bg-white focus:ring-2 focus:ring-[#0F6E56]/10"
|
className="w-full bg-slate-50 border rounded-xl px-3 py-2.5 outline-none focus:bg-white focus:ring-2 focus:ring-[#0F6E56]/10"
|
||||||
placeholder="e.g., Emirates ID Verification Required"
|
placeholder="e.g. Account Update Alert"
|
||||||
value={title}
|
value={title}
|
||||||
onChange={e => setTitle(e.target.value)}
|
onChange={e => setTitle(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Notification Text Message</label>
|
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Message Text</label>
|
||||||
<textarea
|
<textarea
|
||||||
rows="3"
|
rows="3"
|
||||||
required
|
required
|
||||||
className="w-full bg-slate-50 border rounded-xl p-3 outline-none focus:bg-white focus:ring-2 focus:ring-[#0F6E56]/10"
|
className="w-full bg-slate-50 border rounded-xl p-3 outline-none focus:bg-white focus:ring-2 focus:ring-[#0F6E56]/10"
|
||||||
placeholder="Type the message content. Use tags like {{name}} to personalize matches..."
|
placeholder="Type the message..."
|
||||||
value={message}
|
value={message}
|
||||||
onChange={e => setMessage(e.target.value)}
|
onChange={e => setMessage(e.target.value)}
|
||||||
/>
|
/>
|
||||||
@ -122,19 +108,19 @@ export default function NotificationsCampaigns({ campaigns, whatsapp_templates }
|
|||||||
|
|
||||||
<button type="submit" className="w-full py-3 bg-[#0F6E56] text-white rounded-xl text-[10px] font-black uppercase tracking-widest shadow-md hover:bg-[#085041] flex items-center justify-center gap-1.5">
|
<button type="submit" className="w-full py-3 bg-[#0F6E56] text-white rounded-xl text-[10px] font-black uppercase tracking-widest shadow-md hover:bg-[#085041] flex items-center justify-center gap-1.5">
|
||||||
<Send className="w-4 h-4" />
|
<Send className="w-4 h-4" />
|
||||||
<span>Broadcast System Campaign</span>
|
<span>Send Message</span>
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Section 2: Campaigns History */}
|
{/* Recently Sent Logs */}
|
||||||
<div className="bg-white border border-slate-200 rounded-3xl p-6 shadow-sm overflow-hidden">
|
<div className="bg-white border border-slate-200 rounded-3xl p-6 shadow-sm overflow-hidden">
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
<h3 className="text-sm font-black text-slate-800 uppercase tracking-widest flex items-center gap-1.5">
|
<h3 className="text-sm font-black text-slate-800 uppercase tracking-widest flex items-center gap-1.5">
|
||||||
<History className="w-4 h-4 text-slate-600" />
|
<History className="w-4 h-4 text-slate-600" />
|
||||||
<span>Broadcast Campaign Logs</span>
|
<span>History</span>
|
||||||
</h3>
|
</h3>
|
||||||
<p className="text-[10px] text-slate-400 font-semibold mt-0.5">Logs of recently delivered campaigns</p>
|
<p className="text-[10px] text-slate-400 font-semibold mt-0.5">Logs of recently sent alerts</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="divide-y divide-slate-100">
|
<div className="divide-y divide-slate-100">
|
||||||
@ -142,8 +128,8 @@ export default function NotificationsCampaigns({ campaigns, whatsapp_templates }
|
|||||||
<div key={camp.id} className="py-4 hover:bg-slate-50/50 transition-colors flex items-start justify-between gap-4 text-xs font-bold text-slate-700">
|
<div key={camp.id} className="py-4 hover:bg-slate-50/50 transition-colors flex items-start justify-between gap-4 text-xs font-bold text-slate-700">
|
||||||
<div className="space-y-1.5 flex-1">
|
<div className="space-y-1.5 flex-1">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Badge className="bg-blue-50 text-blue-700 border-none font-bold uppercase text-[8px]">{camp.channel}</Badge>
|
<Badge className="bg-[#0F6E56]/10 text-[#0F6E56] border-none font-bold uppercase text-[8px]">{camp.channel}</Badge>
|
||||||
<span className="text-[10px] text-slate-400 font-semibold">Cohort: {camp.recipient_type}</span>
|
<span className="text-[10px] text-slate-400 font-semibold">To: {camp.recipient_type}</span>
|
||||||
</div>
|
</div>
|
||||||
<h4 className="text-sm font-black text-slate-900">{camp.title}</h4>
|
<h4 className="text-sm font-black text-slate-900">{camp.title}</h4>
|
||||||
<p className="text-slate-500 font-medium text-[11px] leading-relaxed">{camp.message}</p>
|
<p className="text-slate-500 font-medium text-[11px] leading-relaxed">{camp.message}</p>
|
||||||
@ -151,7 +137,7 @@ export default function NotificationsCampaigns({ campaigns, whatsapp_templates }
|
|||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<span className="text-[9px] text-slate-400 font-mono block">{camp.sent_at}</span>
|
<span className="text-[9px] text-slate-400 font-mono block">{camp.sent_at}</span>
|
||||||
<div className="mt-2 text-[10px] font-black text-emerald-600 uppercase tracking-wide">
|
<div className="mt-2 text-[10px] font-black text-emerald-600 uppercase tracking-wide">
|
||||||
{camp.delivery_rate} Delivered • {camp.clicks}
|
Sent Successfully
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -160,53 +146,6 @@ export default function NotificationsCampaigns({ campaigns, whatsapp_templates }
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Column 3: Pre-approved WhatsApp Templates */}
|
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="bg-white border border-slate-200 rounded-3xl p-6 shadow-sm space-y-4">
|
|
||||||
<div>
|
|
||||||
<h3 className="text-sm font-black text-slate-800 uppercase tracking-widest flex items-center gap-1">
|
|
||||||
<MessageCircle className="w-4.5 h-4.5 text-emerald-600" />
|
|
||||||
<span>WhatsApp Templates</span>
|
|
||||||
</h3>
|
|
||||||
<p className="text-[10px] text-slate-400 mt-0.5">Meta pre-approved utility check-in templates</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-3">
|
|
||||||
{whatsapp_templates.map((tpl, i) => (
|
|
||||||
<div key={i} className="p-3 bg-slate-50 rounded-xl border border-slate-100 text-xs font-bold text-slate-700 space-y-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="font-black text-slate-800 text-[10px] font-mono leading-none">{tpl.name}</span>
|
|
||||||
<Badge className={`border-none text-[8px] font-black uppercase ${
|
|
||||||
tpl.status === 'Approved' ? 'bg-emerald-50 text-emerald-700' : 'bg-amber-50 text-amber-700'
|
|
||||||
}`}>{tpl.status}</Badge>
|
|
||||||
</div>
|
|
||||||
<p className="text-[10px] text-slate-500 font-medium leading-relaxed bg-white p-2 rounded-lg border border-slate-100">{tpl.text}</p>
|
|
||||||
<div className="text-[8px] text-slate-400 uppercase tracking-widest font-black">Category: {tpl.category} • Language: {tpl.language}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Interactive campaign delivery metrics */}
|
|
||||||
<div className="bg-blue-50/40 border border-blue-100 p-5 rounded-3xl shadow-sm space-y-3 text-xs font-bold text-slate-700">
|
|
||||||
<span className="text-[10px] font-black text-blue-900 uppercase tracking-widest flex items-center gap-1">
|
|
||||||
<TrendingUp className="w-4 h-4" />
|
|
||||||
<span>Engagement Metrics</span>
|
|
||||||
</span>
|
|
||||||
<div className="grid grid-cols-2 gap-4 pt-1">
|
|
||||||
<div className="bg-white p-3 rounded-xl border border-blue-50 text-center">
|
|
||||||
<span className="text-[9px] text-slate-400 uppercase tracking-wider block">Push Open Rate</span>
|
|
||||||
<span className="text-lg font-black text-slate-800">42.8%</span>
|
|
||||||
</div>
|
|
||||||
<div className="bg-white p-3 rounded-xl border border-blue-50 text-center">
|
|
||||||
<span className="text-[9px] text-slate-400 uppercase tracking-wider block">WhatsApp Click Rate</span>
|
|
||||||
<span className="text-lg font-black text-emerald-600">68.5%</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</AdminLayout>
|
</AdminLayout>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -13,7 +13,14 @@ import {
|
|||||||
UserX,
|
UserX,
|
||||||
Settings,
|
Settings,
|
||||||
FileText,
|
FileText,
|
||||||
MessageSquare
|
MessageSquare,
|
||||||
|
Bell,
|
||||||
|
Calendar,
|
||||||
|
Search,
|
||||||
|
ChevronDown,
|
||||||
|
MoreVertical,
|
||||||
|
Clock,
|
||||||
|
Shield
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import {
|
import {
|
||||||
@ -29,110 +36,328 @@ export default function SafetyHub({ reports, rules, moderation_queue }) {
|
|||||||
const [resolutionAction, setResolutionAction] = useState('warn');
|
const [resolutionAction, setResolutionAction] = useState('warn');
|
||||||
const [adminNotes, setAdminNotes] = useState('');
|
const [adminNotes, setAdminNotes] = useState('');
|
||||||
|
|
||||||
|
// Tab and filter states
|
||||||
|
const [activeTab, setActiveTab] = useState('All Reports');
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const [statusFilter, setStatusFilter] = useState('All Status');
|
||||||
|
const [typeFilter, setTypeFilter] = useState('All Types');
|
||||||
|
|
||||||
const handleResolve = (e) => {
|
const handleResolve = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
if (!selectedReport) return;
|
||||||
|
|
||||||
router.post(`/admin/safety/reports/${selectedReport.id}/resolve`, {
|
router.post(`/admin/safety/reports/${selectedReport.id}/resolve`, {
|
||||||
action: resolutionAction,
|
action: resolutionAction,
|
||||||
admin_notes: adminNotes
|
admin_notes: adminNotes
|
||||||
}, {
|
}, {
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
setIsDialogOpen(false);
|
setIsDialogOpen(false);
|
||||||
|
setSelectedReport(null);
|
||||||
|
setAdminNotes('');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleModQueue = (id, action) => {
|
// Filter reports based on active tab, search query, type, and status
|
||||||
router.post(`/admin/safety/reports/${id}/resolve`, { action }, {
|
const filteredReports = (reports || []).filter(report => {
|
||||||
onSuccess: () => {
|
// Tab filter
|
||||||
setIsDialogOpen(false);
|
if (activeTab !== 'All Reports') {
|
||||||
|
if (activeTab === 'Profiles' && report.type !== 'Profile') return false;
|
||||||
|
if (activeTab === 'Chats' && report.type !== 'Chat') return false;
|
||||||
|
if (activeTab === 'Reviews' && report.type !== 'Review') return false;
|
||||||
|
if (activeTab === 'Images' && report.type !== 'Image') return false;
|
||||||
|
if (activeTab === 'Other Content' && report.type === 'Profile' || report.type === 'Chat' || report.type === 'Review' || report.type === 'Image') return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Type filter dropdown
|
||||||
|
if (typeFilter !== 'All Types' && report.type !== typeFilter) return false;
|
||||||
|
|
||||||
|
// Status filter dropdown
|
||||||
|
if (statusFilter !== 'All Status' && report.status !== statusFilter) return false;
|
||||||
|
|
||||||
|
// Search query
|
||||||
|
if (searchQuery.trim() !== '') {
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
return (
|
||||||
|
report.id.toLowerCase().includes(query) ||
|
||||||
|
report.reason.toLowerCase().includes(query) ||
|
||||||
|
report.reported_user_name.toLowerCase().includes(query) ||
|
||||||
|
report.reported_by_name.toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
});
|
});
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminLayout title="Safety & Moderation Center">
|
<AdminLayout title="Content Moderation">
|
||||||
<Head title="Safety & Moderation" />
|
<Head title="Content Moderation" />
|
||||||
|
|
||||||
<div className="font-sans max-w-7xl mx-auto space-y-8">
|
<div className="font-sans max-w-[1600px] mx-auto space-y-6">
|
||||||
|
|
||||||
{/* Header Row */}
|
{/* Header Row */}
|
||||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-bold text-gray-900 tracking-tight">Trust & Safety Escalation Center</h1>
|
<h1 className="text-2xl font-bold text-slate-900 tracking-tight">Content Moderation</h1>
|
||||||
<p className="text-xs text-gray-500 mt-0.5">Manage user abuse reports, content moderation queues, and customize automatic fraud filters.</p>
|
<p className="text-sm text-slate-500 mt-1">Review and take action on reported content, users and reviews.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<button className="inline-flex items-center px-4 py-2.5 bg-white border border-gray-200 text-[#0F6E56] rounded-xl text-xs font-bold hover:bg-gray-50 transition-colors shadow-sm space-x-2 uppercase tracking-wider">
|
{/* Header Controls */}
|
||||||
<Settings className="w-4 h-4" />
|
<div className="flex items-center gap-3 self-end md:self-auto">
|
||||||
<span>System Rule Rules</span>
|
<button className="relative p-2.5 bg-white border border-slate-200 rounded-xl hover:bg-slate-50 transition-colors shadow-sm text-slate-600">
|
||||||
|
<Bell className="w-5 h-5" />
|
||||||
|
<span className="absolute top-1.5 right-1.5 w-4 h-4 bg-red-500 text-white rounded-full text-[9px] font-bold flex items-center justify-center">12</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 px-4 py-2.5 bg-white border border-slate-200 rounded-xl hover:bg-slate-50 transition-colors shadow-sm text-xs font-semibold text-slate-700 cursor-pointer">
|
||||||
|
<Calendar className="w-4 h-4 text-slate-400" />
|
||||||
|
<span>May 13, 2025 - Jun 11, 2025</span>
|
||||||
|
<ChevronDown className="w-4 h-4 text-slate-400" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
{/* Upper Row Metric Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
|
||||||
{/* Column 1 & 2: Abuse Reports & Content Queue */}
|
{/* Card 1: Total Reports */}
|
||||||
<div className="lg:col-span-2 space-y-8">
|
<div className="bg-white p-5 border border-slate-200 rounded-2xl shadow-sm flex items-center justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
{/* Section 1: Abuse Reports */}
|
<span className="text-xs text-slate-500 font-semibold block">Total Reports</span>
|
||||||
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden">
|
<span className="text-2xl font-extrabold text-slate-900 block">128</span>
|
||||||
<div className="p-5 border-b border-slate-100 flex items-center justify-between bg-slate-50/20">
|
<span className="inline-flex items-center gap-0.5 px-2 py-0.5 bg-emerald-50 text-emerald-700 rounded-full text-[10px] font-bold">
|
||||||
<div>
|
↑ 12% <span className="font-normal text-slate-400 ml-1">from last 30 days</span>
|
||||||
<h3 className="text-sm font-black text-slate-800 uppercase tracking-widest">Active Incident Reports</h3>
|
</span>
|
||||||
<p className="text-[10px] text-slate-400 mt-0.5">Escalated complaints filed by employers or workers</p>
|
</div>
|
||||||
|
<div className="p-3 bg-[#0F6E56]/10 text-[#0F6E56] rounded-2xl">
|
||||||
|
<Flag className="w-6 h-6" />
|
||||||
</div>
|
</div>
|
||||||
<Badge className="bg-red-50 text-red-700 border-none font-bold uppercase tracking-wider text-[8px]">{reports?.length || 0} Open Tickets</Badge>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="divide-y divide-slate-100">
|
{/* Card 2: Pending Review */}
|
||||||
{reports.map((report) => (
|
<div className="bg-white p-5 border border-slate-200 rounded-2xl shadow-sm flex items-center justify-between">
|
||||||
<div key={report.id} className="p-5 hover:bg-slate-50/50 transition-colors flex items-start justify-between gap-4 text-xs font-bold text-slate-700">
|
<div className="space-y-1">
|
||||||
<div className="space-y-2 flex-1">
|
<span className="text-xs text-slate-500 font-semibold block">Pending Review</span>
|
||||||
<div className="flex items-center space-x-2">
|
<span className="text-2xl font-extrabold text-slate-900 block">42</span>
|
||||||
<Badge className={`border-none text-[8px] font-black uppercase ${
|
<span className="text-[11px] text-amber-600 font-bold block">Requires attention</span>
|
||||||
report.severity === 'High' ? 'bg-red-100 text-red-700' :
|
|
||||||
report.severity === 'Medium' ? 'bg-amber-100 text-amber-700' : 'bg-slate-100 text-slate-700'
|
|
||||||
}`}>
|
|
||||||
{report.severity} Severity
|
|
||||||
</Badge>
|
|
||||||
<span className="text-[10px] text-slate-400 font-mono">{report.id}</span>
|
|
||||||
<span className="text-slate-300">•</span>
|
|
||||||
<span className="text-slate-400">Reporter: {report.reporter}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="p-3 bg-amber-50 text-amber-600 rounded-2xl">
|
||||||
<h4 className="text-sm font-black text-slate-900">{report.reason}</h4>
|
<Clock className="w-6 h-6" />
|
||||||
<p className="text-slate-500 font-medium mt-1 leading-relaxed">{report.description}</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[10px] text-slate-400 font-semibold uppercase">Reported User: <span className="text-slate-900 font-bold">{report.reported_user}</span></div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-end gap-2">
|
|
||||||
<span className="text-[9px] text-slate-400 font-mono">{report.created_at}</span>
|
{/* Card 4: Resolved */}
|
||||||
|
<div className="bg-white p-5 border border-slate-200 rounded-2xl shadow-sm flex items-center justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className="text-xs text-slate-500 font-semibold block">Resolved</span>
|
||||||
|
<span className="text-2xl font-extrabold text-slate-900 block">78</span>
|
||||||
|
<span className="text-[11px] text-emerald-600 font-bold block">This period</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-emerald-50 text-emerald-600 rounded-2xl">
|
||||||
|
<CheckCircle className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card 5: Actions Taken */}
|
||||||
|
<div className="bg-white p-5 border border-slate-200 rounded-2xl shadow-sm flex items-center justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<span className="text-xs text-slate-500 font-semibold block">Actions Taken</span>
|
||||||
|
<span className="text-2xl font-extrabold text-slate-900 block">56</span>
|
||||||
|
<span className="text-[11px] text-blue-600 font-bold block">Warnings/Suspensions</span>
|
||||||
|
</div>
|
||||||
|
<div className="p-3 bg-blue-50 text-blue-600 rounded-2xl">
|
||||||
|
<Shield className="w-6 h-6" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs & Controls Section */}
|
||||||
|
<div className="bg-white border border-slate-200 rounded-2xl shadow-sm overflow-hidden">
|
||||||
|
<div className="p-4 border-b border-slate-100 flex flex-col lg:flex-row lg:items-center justify-between gap-4 bg-slate-50/20">
|
||||||
|
{/* Tabs list */}
|
||||||
|
<div className="flex items-center overflow-x-auto gap-2 -mb-4 lg:mb-0 pb-2 lg:pb-0 scrollbar-none">
|
||||||
|
{['All Reports', 'Profiles', 'Chats', 'Reviews'].map((tab) => (
|
||||||
<button
|
<button
|
||||||
onClick={() => { setSelectedReport(report); setIsDialogOpen(true); }}
|
key={tab}
|
||||||
className="px-3.5 py-1.5 bg-slate-900 hover:bg-slate-800 text-white rounded-lg font-black text-[9px] uppercase tracking-wider"
|
onClick={() => setActiveTab(tab)}
|
||||||
|
className={`px-4 py-2 text-xs font-bold whitespace-nowrap transition-all rounded-lg ${
|
||||||
|
activeTab === tab
|
||||||
|
? 'bg-[#0F6E56]/10 text-[#0F6E56] font-black shadow-sm'
|
||||||
|
: 'text-slate-500 hover:text-slate-800'
|
||||||
|
}`}
|
||||||
>
|
>
|
||||||
Investigate
|
{tab}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Search & Select Controls */}
|
||||||
|
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-3">
|
||||||
|
{/* Type filter */}
|
||||||
|
<select
|
||||||
|
value={typeFilter}
|
||||||
|
onChange={e => setTypeFilter(e.target.value)}
|
||||||
|
className="bg-white border border-slate-200 text-slate-700 text-xs font-bold px-3 py-2 rounded-xl outline-none cursor-pointer hover:bg-slate-50 transition-colors shadow-sm"
|
||||||
|
>
|
||||||
|
<option value="All Types">All Types</option>
|
||||||
|
<option value="Profile">Profile</option>
|
||||||
|
<option value="Chat">Chat</option>
|
||||||
|
<option value="Review">Review</option>
|
||||||
|
<option value="Image">Image</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Status filter */}
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={e => setStatusFilter(e.target.value)}
|
||||||
|
className="bg-white border border-slate-200 text-slate-700 text-xs font-bold px-3 py-2 rounded-xl outline-none cursor-pointer hover:bg-slate-50 transition-colors shadow-sm"
|
||||||
|
>
|
||||||
|
<option value="All Status">All Status</option>
|
||||||
|
<option value="Pending">Pending</option>
|
||||||
|
<option value="In Review">In Review</option>
|
||||||
|
<option value="Resolved">Resolved</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{/* Search Box */}
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="w-4 h-4 text-slate-400 absolute left-3.5 top-1/2 -translate-y-1/2" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search reports..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={e => setSearchQuery(e.target.value)}
|
||||||
|
className="bg-white border border-slate-200 pl-10 pr-4 py-2 rounded-xl text-xs font-semibold focus:outline-none focus:ring-2 focus:ring-[#0F6E56]/20 w-full sm:w-60 shadow-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Section 2: Content Moderation Queue */}
|
{/* Table Row Content */}
|
||||||
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden">
|
<div className="overflow-x-auto">
|
||||||
<div className="p-5 border-b border-slate-100 bg-slate-50/20">
|
<table className="w-full text-left border-collapse text-xs font-semibold text-slate-600">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-slate-100 bg-slate-50/50 text-slate-400 font-bold uppercase tracking-wider text-[10px]">
|
||||||
|
<th className="px-5 py-4 w-10">
|
||||||
|
<input type="checkbox" className="rounded border-slate-300 text-[#0F6E56] focus:ring-[#0F6E56] cursor-pointer" />
|
||||||
|
</th>
|
||||||
|
<th className="px-5 py-4">Report ID</th>
|
||||||
|
<th className="px-5 py-4">Type</th>
|
||||||
|
<th className="px-5 py-4">Reported User</th>
|
||||||
|
<th className="px-5 py-4">Reported By</th>
|
||||||
|
<th className="px-5 py-4">Reason</th>
|
||||||
|
<th className="px-5 py-4">Status</th>
|
||||||
|
<th className="px-5 py-4">Reported At</th>
|
||||||
|
<th className="px-5 py-4 text-right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-100">
|
||||||
|
{filteredReports.map((report) => (
|
||||||
|
<tr key={report.id} className="hover:bg-slate-50/40 transition-colors">
|
||||||
|
<td className="px-5 py-4">
|
||||||
|
<input type="checkbox" className="rounded border-slate-300 text-[#0F6E56] focus:ring-[#0F6E56] cursor-pointer" />
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-4 font-mono font-bold text-[#0F6E56] hover:underline cursor-pointer">
|
||||||
|
{report.id}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-4">
|
||||||
|
<span className={`px-2.5 py-1 rounded-full text-[10px] font-black uppercase ${
|
||||||
|
report.type === 'Profile' ? 'bg-purple-100 text-purple-700' :
|
||||||
|
report.type === 'Chat' ? 'bg-cyan-100 text-cyan-700' :
|
||||||
|
report.type === 'Review' ? 'bg-emerald-100 text-emerald-700' : 'bg-orange-100 text-orange-700'
|
||||||
|
}`}>
|
||||||
|
{report.type}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-4">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<img
|
||||||
|
src={report.reported_user_avatar || 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?auto=format&fit=crop&q=80&w=100'}
|
||||||
|
alt={report.reported_user_name}
|
||||||
|
className="w-7 h-7 rounded-full object-cover shadow-sm border border-slate-100"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<span className="font-extrabold text-slate-800 block">{report.reported_user_name}</span>
|
||||||
|
<span className="text-[10px] text-slate-400 font-semibold block">{report.reported_user_role}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-4">
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
<img
|
||||||
|
src={report.reported_by_avatar || 'https://images.unsplash.com/photo-1544005313-94ddf0286df2?auto=format&fit=crop&q=80&w=100'}
|
||||||
|
alt={report.reported_by_name}
|
||||||
|
className="w-7 h-7 rounded-full object-cover shadow-sm border border-slate-100"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<span className="font-extrabold text-slate-800 block">{report.reported_by_name}</span>
|
||||||
|
<span className="text-[10px] text-slate-400 font-semibold block">{report.reported_by_role}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-4 font-semibold text-slate-800">
|
||||||
|
{report.reason}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-4">
|
||||||
|
<span className={`px-2 py-0.5 rounded-full text-[10px] font-black uppercase ${
|
||||||
|
report.status === 'Pending' ? 'bg-orange-50 text-orange-700' :
|
||||||
|
report.status === 'In Review' ? 'bg-blue-50 text-blue-700' : 'bg-emerald-50 text-emerald-700'
|
||||||
|
}`}>
|
||||||
|
{report.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-4 text-slate-400 font-semibold">
|
||||||
|
{report.reported_at}
|
||||||
|
</td>
|
||||||
|
<td className="px-5 py-4 text-right">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => { setSelectedReport(report); setIsDialogOpen(true); }}
|
||||||
|
className="px-3 py-1.5 bg-white border border-slate-200 text-slate-700 rounded-lg hover:bg-slate-50 transition-colors shadow-sm font-bold uppercase tracking-wider text-[10px]"
|
||||||
|
>
|
||||||
|
View
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setSelectedReport(report); setIsDialogOpen(true); }}
|
||||||
|
className="px-3.5 py-1.5 bg-[#0F6E56] hover:bg-[#085041] text-white rounded-lg transition-colors font-black uppercase tracking-wider text-[10px]"
|
||||||
|
>
|
||||||
|
Take Action
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{filteredReports.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan="10" className="p-10 text-center font-bold text-slate-400 uppercase">
|
||||||
|
No incident reports found matching your criteria.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Subsections: Vetting Queue & Auto-Detection Rules */}
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
{/* Content Vetting Queue */}
|
||||||
|
<div className="lg:col-span-2 bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden">
|
||||||
|
<div className="p-5 border-b border-slate-100 flex items-center justify-between bg-slate-50/20">
|
||||||
|
<div>
|
||||||
<h3 className="text-sm font-black text-slate-800 uppercase tracking-widest">Profile Content Vetting Queue</h3>
|
<h3 className="text-sm font-black text-slate-800 uppercase tracking-widest">Profile Content Vetting Queue</h3>
|
||||||
<p className="text-[10px] text-slate-400 mt-0.5">Bio updates or pictures flagged for manual review</p>
|
<p className="text-[10px] text-slate-400 mt-0.5">Bio updates or pictures flagged for manual review</p>
|
||||||
</div>
|
</div>
|
||||||
|
<Badge className="bg-amber-50 text-amber-700 border-none font-bold uppercase tracking-wider text-[8px]">Action Required</Badge>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="divide-y divide-slate-100">
|
<div className="divide-y divide-slate-100">
|
||||||
{moderation_queue.map((item) => (
|
{moderation_queue.map((item) => (
|
||||||
<div key={item.id} className="p-5 hover:bg-slate-50/30 transition-colors grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-bold text-slate-700">
|
<div key={item.id} className="p-5 hover:bg-slate-50/20 transition-colors grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-bold text-slate-700">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-[9px] text-slate-400 uppercase tracking-widest block font-mono">{item.id} • {item.type}</span>
|
<span className="text-[9px] text-slate-400 uppercase tracking-widest block font-mono">{item.id} • {item.type}</span>
|
||||||
<span className="text-sm font-black text-slate-900 block mt-1">{item.user}</span>
|
<span className="text-sm font-black text-slate-900 block mt-1">{item.user}</span>
|
||||||
<Badge className="mt-2 bg-amber-50 text-amber-700 border-none font-bold uppercase tracking-wider text-[8px]">{item.flag}</Badge>
|
<Badge className="mt-2 bg-red-50 text-red-700 border-none font-bold uppercase tracking-wider text-[8px]">{item.flag}</Badge>
|
||||||
</div>
|
</div>
|
||||||
<div className="sm:col-span-2 flex flex-col justify-between items-end gap-3">
|
<div className="sm:col-span-2 flex flex-col justify-between items-end gap-3">
|
||||||
<div className="bg-slate-50 p-3 rounded-xl border border-slate-100 text-slate-600 font-medium w-full text-[11px] leading-relaxed">
|
<div className="bg-slate-50 p-3 rounded-xl border border-slate-100 text-slate-600 font-medium w-full text-[11px] leading-relaxed">
|
||||||
@ -141,16 +366,10 @@ export default function SafetyHub({ reports, rules, moderation_queue }) {
|
|||||||
) : item.content}
|
) : item.content}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button className="px-3.5 py-1.5 bg-emerald-600 hover:bg-emerald-500 text-white rounded-lg text-[9px] font-black uppercase tracking-wider transition-colors shadow-sm">
|
||||||
onClick={() => handleModQueue(item.id, 'approve')}
|
|
||||||
className="px-3.5 py-1.5 bg-emerald-600 hover:bg-emerald-500 text-white rounded-lg text-[9px] font-black uppercase tracking-wider"
|
|
||||||
>
|
|
||||||
Approve Content
|
Approve Content
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button className="px-3.5 py-1.5 bg-red-600 hover:bg-red-500 text-white rounded-lg text-[9px] font-black uppercase tracking-wider transition-colors shadow-sm">
|
||||||
onClick={() => handleModQueue(item.id, 'reject')}
|
|
||||||
className="px-3.5 py-1.5 bg-red-600 hover:bg-red-500 text-white rounded-lg text-[9px] font-black uppercase tracking-wider"
|
|
||||||
>
|
|
||||||
Reject / Delete
|
Reject / Delete
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -160,11 +379,9 @@ export default function SafetyHub({ reports, rules, moderation_queue }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
{/* Auto-Detection Rules */}
|
||||||
|
<div className="bg-white p-5 border border-slate-200 rounded-2xl shadow-sm flex flex-col justify-between">
|
||||||
{/* Column 3: Auto-Flag Detection Rules */}
|
<div className="space-y-4">
|
||||||
<div className="space-y-6">
|
|
||||||
<div className="bg-white p-5 border border-slate-200 rounded-2xl shadow-sm space-y-4">
|
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-black text-slate-800 uppercase tracking-widest">Auto-Detection Rules</h3>
|
<h3 className="text-sm font-black text-slate-800 uppercase tracking-widest">Auto-Detection Rules</h3>
|
||||||
<p className="text-[10px] text-slate-400 mt-0.5">Parameters triggering system automated flags</p>
|
<p className="text-[10px] text-slate-400 mt-0.5">Parameters triggering system automated flags</p>
|
||||||
@ -183,30 +400,21 @@ export default function SafetyHub({ reports, rules, moderation_queue }) {
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button className="w-full py-3 bg-[#0F6E56] text-white rounded-xl text-[10px] font-black uppercase tracking-widest shadow-md hover:bg-[#085041] flex items-center justify-center gap-1.5">
|
<button className="w-full mt-4 py-3 bg-[#0F6E56] text-white rounded-xl text-[10px] font-black uppercase tracking-widest shadow-md hover:bg-[#085041] flex items-center justify-center gap-1.5 transition-colors">
|
||||||
<Plus className="w-3.5 h-3.5" />
|
<Plus className="w-3.5 h-3.5" />
|
||||||
<span>Create Custom Rule</span>
|
<span>Create Custom Rule</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Keyword Abuse escalating info */}
|
|
||||||
<div className="bg-amber-50/50 p-5 border border-amber-200 rounded-2xl shadow-sm space-y-3">
|
|
||||||
<span className="text-[10px] font-black text-amber-800 uppercase tracking-widest flex items-center gap-1">
|
|
||||||
<ShieldAlert className="w-4 h-4" />
|
|
||||||
<span>Direct Chat Abuse Triggered Logs</span>
|
|
||||||
</span>
|
|
||||||
<p className="text-xs text-slate-600 font-semibold leading-relaxed">System triggered 14 keyword warning notices to employers for sharing email/phone details in chats prior to hiring offer completions today.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Investigation Dialog Modal */}
|
{/* Investigation & Action Dialog Modal */}
|
||||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||||
<DialogContent className="max-w-xl bg-white p-6 rounded-[24px] border-none shadow-2xl font-sans">
|
<DialogContent className="max-w-xl bg-white p-6 rounded-[24px] border-none shadow-2xl font-sans">
|
||||||
<DialogHeader className="mb-4">
|
<DialogHeader className="mb-4">
|
||||||
<DialogTitle className="text-lg font-black text-slate-800 flex items-center gap-1">
|
<DialogTitle className="text-lg font-black text-slate-800 flex items-center gap-2">
|
||||||
<ShieldAlert className="w-5 h-5 text-red-600" />
|
<ShieldAlert className="w-5 h-5 text-red-600" />
|
||||||
<span>Incident Investigation: <span className="font-mono text-slate-400">{selectedReport?.id}</span></span>
|
<span>Incident Investigation: <span className="font-mono text-slate-400">{selectedReport?.id}</span></span>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
@ -215,18 +423,20 @@ export default function SafetyHub({ reports, rules, moderation_queue }) {
|
|||||||
<form onSubmit={handleResolve} className="space-y-4 text-xs font-bold text-slate-700">
|
<form onSubmit={handleResolve} className="space-y-4 text-xs font-bold text-slate-700">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="text-[9px] font-black text-slate-400 uppercase tracking-widest">Reason / Incident Subject</label>
|
<label className="text-[9px] font-black text-slate-400 uppercase tracking-widest">Reason / Incident Subject</label>
|
||||||
<div className="p-3 bg-slate-50 border rounded-xl font-black text-slate-800">{selectedReport?.reason}</div>
|
<div className="p-3 bg-slate-50 border border-slate-200 rounded-xl font-black text-slate-800">{selectedReport?.reason}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="text-[9px] font-black text-slate-400 uppercase tracking-widest">Report Description</label>
|
<label className="text-[9px] font-black text-slate-400 uppercase tracking-widest">Report Description</label>
|
||||||
<p className="p-3 bg-slate-50 border rounded-xl font-medium text-slate-600 leading-relaxed">{selectedReport?.description}</p>
|
<p className="p-3 bg-slate-50 border border-slate-200 rounded-xl font-medium text-slate-600 leading-relaxed">
|
||||||
|
{selectedReport?.description || 'No description details provided.'}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="text-[9px] font-black text-slate-400 uppercase tracking-widest">Select Resolution Action</label>
|
<label className="text-[9px] font-black text-slate-400 uppercase tracking-widest">Select Resolution Action</label>
|
||||||
<select
|
<select
|
||||||
className="w-full bg-slate-50 border rounded-xl px-3 py-2.5 font-bold outline-none cursor-pointer focus:bg-white"
|
className="w-full bg-slate-50 border border-slate-200 rounded-xl px-3 py-2.5 font-bold outline-none cursor-pointer focus:bg-white transition-colors"
|
||||||
value={resolutionAction}
|
value={resolutionAction}
|
||||||
onChange={e => setResolutionAction(e.target.value)}
|
onChange={e => setResolutionAction(e.target.value)}
|
||||||
>
|
>
|
||||||
@ -242,7 +452,7 @@ export default function SafetyHub({ reports, rules, moderation_queue }) {
|
|||||||
<textarea
|
<textarea
|
||||||
rows="3"
|
rows="3"
|
||||||
placeholder="State findings of direct message logs audits or phone outreach validation..."
|
placeholder="State findings of direct message logs audits or phone outreach validation..."
|
||||||
className="w-full bg-white border rounded-xl p-3 font-semibold focus:ring-2 focus:ring-[#0F6E56]/10 outline-none"
|
className="w-full bg-white border border-slate-200 rounded-xl p-3 font-semibold focus:ring-2 focus:ring-[#0F6E56]/10 outline-none"
|
||||||
value={adminNotes}
|
value={adminNotes}
|
||||||
onChange={e => setAdminNotes(e.target.value)}
|
onChange={e => setAdminNotes(e.target.value)}
|
||||||
/>
|
/>
|
||||||
@ -251,14 +461,14 @@ export default function SafetyHub({ reports, rules, moderation_queue }) {
|
|||||||
<div className="flex gap-2 pt-2 justify-end">
|
<div className="flex gap-2 pt-2 justify-end">
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="px-5 py-2.5 bg-slate-900 hover:bg-slate-800 text-white rounded-xl text-[10px] font-black uppercase tracking-widest"
|
className="px-5 py-2.5 bg-[#0F6E56] hover:bg-[#085041] text-white rounded-xl text-[10px] font-black uppercase tracking-widest transition-colors shadow-md"
|
||||||
>
|
>
|
||||||
Submit Findings
|
Submit Findings
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setIsDialogOpen(false)}
|
onClick={() => setIsDialogOpen(false)}
|
||||||
className="px-4 py-2.5 bg-slate-100 hover:bg-slate-200 text-slate-700 rounded-xl text-[10px] font-black uppercase tracking-widest"
|
className="px-4 py-2.5 bg-slate-100 hover:bg-slate-200 text-slate-700 rounded-xl text-[10px] font-black uppercase tracking-widest transition-colors"
|
||||||
>
|
>
|
||||||
Close
|
Close
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -2,21 +2,15 @@ import React, { useState } from 'react';
|
|||||||
import { Head, router } from '@inertiajs/react';
|
import { Head, router } from '@inertiajs/react';
|
||||||
import AdminLayout from '@/Layouts/AdminLayout';
|
import AdminLayout from '@/Layouts/AdminLayout';
|
||||||
import {
|
import {
|
||||||
Search,
|
|
||||||
FileText,
|
|
||||||
Eye,
|
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
XCircle,
|
XCircle,
|
||||||
Clock,
|
Clock,
|
||||||
Edit2,
|
|
||||||
ZoomIn,
|
|
||||||
X,
|
X,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
Edit3,
|
Edit3,
|
||||||
History,
|
History,
|
||||||
Sparkles,
|
Sparkles
|
||||||
CheckSquare
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import {
|
import {
|
||||||
@ -29,7 +23,6 @@ import {
|
|||||||
export default function Verifications({ verifications, status }) {
|
export default function Verifications({ verifications, status }) {
|
||||||
const [selectedItem, setSelectedItem] = useState(null);
|
const [selectedItem, setSelectedItem] = useState(null);
|
||||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
const [lightboxImage, setLightboxImage] = useState(null);
|
|
||||||
|
|
||||||
// Vetting form overrides
|
// Vetting form overrides
|
||||||
const [ocrOverrides, setOcrOverrides] = useState({});
|
const [ocrOverrides, setOcrOverrides] = useState({});
|
||||||
@ -37,7 +30,7 @@ export default function Verifications({ verifications, status }) {
|
|||||||
const [rejectionReason, setRejectionReason] = useState('');
|
const [rejectionReason, setRejectionReason] = useState('');
|
||||||
const [showRejectionForm, setShowRejectionForm] = useState(false);
|
const [showRejectionForm, setShowRejectionForm] = useState(false);
|
||||||
|
|
||||||
// Local mock verification queue containing OCR score mismatches
|
// Local queue of workers to verify
|
||||||
const localQueue = [
|
const localQueue = [
|
||||||
{
|
{
|
||||||
id: 101,
|
id: 101,
|
||||||
@ -47,12 +40,9 @@ export default function Verifications({ verifications, status }) {
|
|||||||
processed_at: '2026-05-23 14:15',
|
processed_at: '2026-05-23 14:15',
|
||||||
status: 'approved',
|
status: 'approved',
|
||||||
confidence: 98,
|
confidence: 98,
|
||||||
verification_method: 'Auto-OCR',
|
verification_method: 'Auto',
|
||||||
document_type: 'Passport & Visa Scan',
|
document_type: 'Passport',
|
||||||
warnings: [],
|
warnings: [],
|
||||||
document_images: [
|
|
||||||
'https://images.unsplash.com/photo-1544717305-2782549b5136?q=80&w=600&auto=format&fit=crop',
|
|
||||||
],
|
|
||||||
ocr_data: {
|
ocr_data: {
|
||||||
'Name': 'Fatima Zahra',
|
'Name': 'Fatima Zahra',
|
||||||
'DOB': '1992-08-14',
|
'DOB': '1992-08-14',
|
||||||
@ -69,12 +59,9 @@ export default function Verifications({ verifications, status }) {
|
|||||||
processed_at: '2026-05-23 11:30',
|
processed_at: '2026-05-23 11:30',
|
||||||
status: 'flagged',
|
status: 'flagged',
|
||||||
confidence: 58,
|
confidence: 58,
|
||||||
verification_method: 'Auto-OCR',
|
verification_method: 'Auto',
|
||||||
document_type: 'Passport Scan',
|
document_type: 'Passport',
|
||||||
warnings: ['Blurry text region', 'Signature discrepancy flagged'],
|
warnings: ['Blurry text region', 'Signature discrepancy flagged'],
|
||||||
document_images: [
|
|
||||||
'https://images.unsplash.com/photo-1580489944761-15a19d654956?q=80&w=600&auto=format&fit=crop'
|
|
||||||
],
|
|
||||||
ocr_data: {
|
ocr_data: {
|
||||||
'Name': 'Amina Diop',
|
'Name': 'Amina Diop',
|
||||||
'DOB': '1994-12-01',
|
'DOB': '1994-12-01',
|
||||||
@ -91,12 +78,9 @@ export default function Verifications({ verifications, status }) {
|
|||||||
processed_at: '2026-05-22 16:40',
|
processed_at: '2026-05-22 16:40',
|
||||||
status: 'flagged',
|
status: 'flagged',
|
||||||
confidence: 62,
|
confidence: 62,
|
||||||
verification_method: 'Auto-OCR',
|
verification_method: 'Auto',
|
||||||
document_type: 'Visa scan details',
|
document_type: 'Visa scan details',
|
||||||
warnings: ['Document expiration alert (Expires in 5 days)'],
|
warnings: ['Document expiration alert (Expires in 5 days)'],
|
||||||
document_images: [
|
|
||||||
'https://images.unsplash.com/photo-1534528741775-53994a69daeb?q=80&w=600&auto=format&fit=crop'
|
|
||||||
],
|
|
||||||
ocr_data: {
|
ocr_data: {
|
||||||
'Name': 'Siti Aminah',
|
'Name': 'Siti Aminah',
|
||||||
'DOB': '1988-03-25',
|
'DOB': '1988-03-25',
|
||||||
@ -135,24 +119,24 @@ export default function Verifications({ verifications, status }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AdminLayout title="Vetting & OCR Review Hub">
|
<AdminLayout title="Verifications">
|
||||||
<Head title="OCR Verifications" />
|
<Head title="Verifications" />
|
||||||
|
|
||||||
<div className="font-sans max-w-7xl mx-auto space-y-6">
|
<div className="font-sans max-w-7xl mx-auto space-y-6 pb-12">
|
||||||
|
|
||||||
{/* Header overview and status */}
|
{/* Simplified Header */}
|
||||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 border-b border-slate-200 pb-5">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-bold text-gray-900 tracking-tight">OCR Vetting Queue & Verification System</h1>
|
<h1 className="text-xl font-black text-slate-800 uppercase tracking-tight">Worker Verifications</h1>
|
||||||
<p className="text-xs text-gray-500 mt-0.5">High-confidence auto-approvals, low-confidence warning audits, and inline OCR override triggers.</p>
|
<p className="text-xs text-slate-500 mt-0.5 font-medium">Verify workers, check passports, and override data inputs.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Filter Tabs */}
|
{/* Filter Tabs */}
|
||||||
<div className="flex bg-slate-200/60 p-1 rounded-xl w-fit border border-slate-200 font-sans">
|
<div className="flex bg-slate-100 p-1 rounded-xl w-fit border border-slate-200">
|
||||||
{[
|
{[
|
||||||
{ label: 'All Audits', value: 'all' },
|
{ label: 'All', value: 'all' },
|
||||||
{ label: 'Auto-Approved (High Confidence)', value: 'approved' },
|
{ label: 'Approved', value: 'approved' },
|
||||||
{ label: 'Vetting Flags (Low Confidence)', value: 'rejected' },
|
{ label: 'Flagged', value: 'rejected' },
|
||||||
].map((tab) => (
|
].map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab.value}
|
key={tab.value}
|
||||||
@ -161,7 +145,7 @@ export default function Verifications({ verifications, status }) {
|
|||||||
className={`px-4 py-1.5 rounded-lg text-[10px] font-black uppercase tracking-wider transition-all ${
|
className={`px-4 py-1.5 rounded-lg text-[10px] font-black uppercase tracking-wider transition-all ${
|
||||||
(status || 'all') === tab.value
|
(status || 'all') === tab.value
|
||||||
? 'bg-white text-[#0F6E56] shadow-sm'
|
? 'bg-white text-[#0F6E56] shadow-sm'
|
||||||
: 'text-slate-600 hover:text-slate-900'
|
: 'text-slate-650 hover:text-slate-900'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{tab.label}
|
{tab.label}
|
||||||
@ -174,43 +158,43 @@ export default function Verifications({ verifications, status }) {
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
<div className="bg-white p-5 border border-slate-200 rounded-2xl flex items-center justify-between shadow-sm">
|
<div className="bg-white p-5 border border-slate-200 rounded-2xl flex items-center justify-between shadow-sm">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest block">System Auto-Approve Rate</span>
|
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest block">Auto-Approve Rate</span>
|
||||||
<span className="text-2xl font-black text-slate-800">92.4%</span>
|
<span className="text-2xl font-black text-slate-800 mt-1">92.4%</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-10 h-10 bg-emerald-50 rounded-xl flex items-center justify-center">
|
<div className="w-10 h-10 bg-emerald-50 rounded-xl flex items-center justify-center text-emerald-600">
|
||||||
<ShieldCheck className="w-5 h-5 text-emerald-600" />
|
<ShieldCheck className="w-5 h-5" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white p-5 border border-slate-200 rounded-2xl flex items-center justify-between shadow-sm">
|
<div className="bg-white p-5 border border-slate-200 rounded-2xl flex items-center justify-between shadow-sm">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest block">OCR Average Confidence Score</span>
|
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest block">Average Match Score</span>
|
||||||
<span className="text-2xl font-black text-blue-600">91.8%</span>
|
<span className="text-2xl font-black text-blue-600 mt-1">91.8%</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-10 h-10 bg-blue-50 rounded-xl flex items-center justify-center">
|
<div className="w-10 h-10 bg-blue-50 rounded-xl flex items-center justify-center text-blue-600">
|
||||||
<Sparkles className="w-5 h-5 text-blue-600" />
|
<Sparkles className="w-5 h-5" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white p-5 border border-slate-200 rounded-2xl flex items-center justify-between shadow-sm">
|
<div className="bg-white p-5 border border-slate-200 rounded-2xl flex items-center justify-between shadow-sm">
|
||||||
<div>
|
<div>
|
||||||
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest block">Vetting Queue Flags</span>
|
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest block">Flagged Workers</span>
|
||||||
<span className="text-2xl font-black text-amber-600">2 Pending review</span>
|
<span className="text-2xl font-black text-amber-600 mt-1">2 Pending</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-10 h-10 bg-amber-50 rounded-xl flex items-center justify-center">
|
<div className="w-10 h-10 bg-amber-50 rounded-xl flex items-center justify-center text-amber-600">
|
||||||
<AlertTriangle className="w-5 h-5 text-amber-600" />
|
<AlertTriangle className="w-5 h-5" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Verification Queue Datagrid */}
|
{/* Verification Queue Datagrid */}
|
||||||
<div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden">
|
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-left border-collapse">
|
<table className="w-full text-left border-collapse">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="bg-slate-50/70 border-b border-slate-100 text-[10px] font-black text-slate-500 uppercase tracking-widest">
|
<tr className="bg-slate-50/70 border-b border-slate-150 text-[10px] font-black text-slate-500 uppercase tracking-widest">
|
||||||
<th className="py-4 px-6">Worker Name</th>
|
<th className="py-4 px-6">Worker Name</th>
|
||||||
<th className="py-4 px-6">Passport ID</th>
|
<th className="py-4 px-6">Passport ID</th>
|
||||||
<th className="py-4 px-6">OCR Confidence Score</th>
|
<th className="py-4 px-6">Confidence Score</th>
|
||||||
<th className="py-4 px-6">Vetting Warnings</th>
|
<th className="py-4 px-6">Warnings</th>
|
||||||
<th className="py-4 px-6">Processed Date</th>
|
<th className="py-4 px-6">Processed Date</th>
|
||||||
<th className="py-4 px-6 text-right">Action</th>
|
<th className="py-4 px-6 text-right">Action</th>
|
||||||
</tr>
|
</tr>
|
||||||
@ -221,13 +205,13 @@ export default function Verifications({ verifications, status }) {
|
|||||||
<td className="py-4 px-6">
|
<td className="py-4 px-6">
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-black text-slate-900">{item.worker_name}</div>
|
<div className="text-sm font-black text-slate-900">{item.worker_name}</div>
|
||||||
<div className="text-[10px] text-slate-400 font-semibold">{item.nationality} • {item.document_type}</div>
|
<div className="text-[10px] text-slate-400 font-semibold mt-0.5">{item.nationality} • {item.document_type}</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="py-4 px-6 font-mono font-medium">{item.passport_number}</td>
|
<td className="py-4 px-6 font-mono font-semibold text-slate-650">{item.passport_number}</td>
|
||||||
<td className="py-4 px-6">
|
<td className="py-4 px-6">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<div className="w-24 bg-slate-100 rounded-full h-2.5 overflow-hidden">
|
<div className="w-24 bg-slate-100 rounded-full h-2 overflow-hidden">
|
||||||
<div
|
<div
|
||||||
className={`h-full rounded-full ${
|
className={`h-full rounded-full ${
|
||||||
item.confidence > 90 ? 'bg-emerald-500' :
|
item.confidence > 90 ? 'bg-emerald-500' :
|
||||||
@ -244,15 +228,15 @@ export default function Verifications({ verifications, status }) {
|
|||||||
</td>
|
</td>
|
||||||
<td className="py-4 px-6">
|
<td className="py-4 px-6">
|
||||||
{item.warnings.length > 0 ? (
|
{item.warnings.length > 0 ? (
|
||||||
<div className="space-y-1">
|
<div className="flex flex-wrap gap-1">
|
||||||
{item.warnings.map((w, idx) => (
|
{item.warnings.map((w, idx) => (
|
||||||
<span key={idx} className="inline-flex items-center bg-red-50 text-red-700 text-[9px] px-2 py-0.5 rounded-full uppercase">
|
<span key={idx} className="inline-flex items-center bg-red-50 text-red-700 text-[9px] px-2.5 py-0.5 rounded-full uppercase font-black tracking-wide border border-red-100">
|
||||||
<AlertTriangle className="w-2.5 h-2.5 mr-1" /> {w}
|
{w}
|
||||||
</span>
|
</span>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-[10px] font-black text-emerald-600 uppercase">None (Safe)</span>
|
<span className="text-[10px] font-black text-emerald-600 uppercase">None</span>
|
||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td className="py-4 px-6 text-slate-400 font-semibold">{item.processed_at}</td>
|
<td className="py-4 px-6 text-slate-400 font-semibold">{item.processed_at}</td>
|
||||||
@ -262,11 +246,11 @@ export default function Verifications({ verifications, status }) {
|
|||||||
onClick={() => openReview(item)}
|
onClick={() => openReview(item)}
|
||||||
className={`inline-flex items-center px-4 py-2 border rounded-xl text-[10px] font-black uppercase tracking-wider transition-colors focus:outline-none ${
|
className={`inline-flex items-center px-4 py-2 border rounded-xl text-[10px] font-black uppercase tracking-wider transition-colors focus:outline-none ${
|
||||||
item.status === 'flagged'
|
item.status === 'flagged'
|
||||||
? 'bg-amber-500 text-white border-none shadow-md shadow-amber-500/20 hover:bg-amber-600'
|
? 'bg-amber-500 text-white border-none shadow-md hover:bg-amber-600'
|
||||||
: 'bg-white hover:bg-slate-50 text-slate-700 border-slate-200'
|
: 'bg-white hover:bg-slate-50 text-slate-700 border-slate-200'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{item.status === 'flagged' ? 'Verify OCR' : 'Audit Log'}
|
{item.status === 'flagged' ? 'Verify' : 'Logs'}
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@ -277,97 +261,54 @@ export default function Verifications({ verifications, status }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Audit logs details grid */}
|
{/* Audit logs details grid */}
|
||||||
<div className="bg-slate-50 rounded-2xl p-5 border border-slate-200">
|
<div className="bg-white rounded-2xl p-6 border border-slate-200 shadow-sm space-y-4">
|
||||||
<h3 className="text-xs font-black text-slate-800 uppercase tracking-widest flex items-center gap-1.5 mb-3">
|
<h3 className="text-xs font-black text-slate-800 uppercase tracking-widest flex items-center gap-1.5 border-b border-slate-100 pb-3">
|
||||||
<History className="w-4 h-4 text-slate-600" />
|
<History className="w-4 h-4 text-slate-600" />
|
||||||
<span>OCR Vetting Action Audit Logs</span>
|
<span>Verification History</span>
|
||||||
</h3>
|
</h3>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{[
|
{[
|
||||||
{ time: '2026-05-23 15:20', text: 'Admin Vetting Officer manually approved passport scan verification for Leila Bekri', ip: '192.168.1.5' },
|
{ time: '2026-05-23 15:20', text: 'Admin manual approval completed for Leila Bekri passport verification', ip: '192.168.1.5' },
|
||||||
{ time: '2026-05-23 14:15', text: 'System OCR Passport auto-verification completed successfully for Fatima Zahra', ip: 'Auto-System' },
|
{ time: '2026-05-23 14:15', text: 'Auto passport verification completed successfully for Fatima Zahra', ip: 'Auto-System' },
|
||||||
{ time: '2026-05-23 13:42', text: 'System flagged passport scan of Grace Omondi due to blurred signature signature check failure', ip: 'Auto-System' },
|
{ time: '2026-05-23 13:42', text: 'Auto verification flagged passport scan of Grace Omondi due to blurred signature scan', ip: 'Auto-System' },
|
||||||
].map((log, idx) => (
|
].map((log, idx) => (
|
||||||
<div key={idx} className="bg-white p-3 rounded-xl border border-slate-100 flex items-center justify-between text-xs font-bold text-slate-700 shadow-sm">
|
<div key={idx} className="bg-slate-50 p-3.5 rounded-xl border border-slate-100 flex items-center justify-between text-xs font-bold text-slate-700 shadow-inner">
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<Clock className="w-4 h-4 text-slate-400" />
|
<Clock className="w-4 h-4 text-slate-400" />
|
||||||
<span>{log.text}</span>
|
<span>{log.text}</span>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-[10px] text-slate-400">{log.time} • IP: {log.ip}</span>
|
<span className="text-[10px] text-slate-400 font-semibold">{log.time} • IP: {log.ip}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Vetting Vetting Audit Dialog */}
|
{/* Verification Audit Dialog - Simplified & Width Optimized */}
|
||||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||||
<DialogContent className="sm:max-w-4xl w-full bg-white p-0 overflow-hidden font-sans rounded-[24px] border-none shadow-2xl max-h-[90vh] flex flex-col">
|
<DialogContent className="sm:max-w-xl w-full bg-white p-0 overflow-hidden font-sans rounded-[24px] border-none shadow-2xl max-h-[90vh] flex flex-col">
|
||||||
<DialogHeader className="p-6 border-b border-slate-100 bg-[#0f6e56]/5 flex-shrink-0">
|
<DialogHeader className="p-6 border-b border-slate-100 bg-[#0f6e56]/5 flex-shrink-0">
|
||||||
<DialogTitle className="text-lg font-black text-slate-800 flex items-center justify-between tracking-tight">
|
<DialogTitle className="text-lg font-black text-slate-800 flex items-center justify-between tracking-tight">
|
||||||
<span className="flex items-center gap-2">
|
<span className="flex items-center gap-2">
|
||||||
<ShieldCheck className="w-5 h-5 text-[#0F6E56]" />
|
<ShieldCheck className="w-5 h-5 text-[#0F6E56]" />
|
||||||
<span>Vetting Manual Audit: <span className="text-[#0F6E56]">{selectedItem?.worker_name}</span></span>
|
<span>Verify Worker: <span className="text-[#0F6E56]">{selectedItem?.worker_name}</span></span>
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Badge className={`border-none px-3.5 py-1.5 font-black text-[9px] uppercase tracking-wider rounded-lg shadow-sm flex items-center gap-1 ${
|
<Badge className="border-none bg-[#0F6E56] text-white px-3.5 py-1.5 font-black text-[9px] uppercase tracking-wider rounded-lg shadow-sm">
|
||||||
selectedItem?.confidence > 80
|
<span>Score: {selectedItem?.confidence}%</span>
|
||||||
? 'bg-emerald-500 text-white shadow-emerald-500/20'
|
|
||||||
: 'bg-amber-500 text-white shadow-amber-500/20'
|
|
||||||
}`}>
|
|
||||||
<Sparkles className="w-3.5 h-3.5 animate-pulse" />
|
|
||||||
<span>OCR Confidence: {selectedItem?.confidence}%</span>
|
|
||||||
</Badge>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 p-6 overflow-y-auto flex-1 bg-slate-50/30">
|
<div className="p-6 overflow-y-auto flex-1 bg-slate-50/30 space-y-6">
|
||||||
|
|
||||||
{/* Scanned proof & Warnings Column */}
|
{/* Warnings Box - Rendered Inline */}
|
||||||
<div className="space-y-5">
|
{selectedItem?.warnings?.length > 0 && (
|
||||||
<div className="bg-white p-4 rounded-2xl border border-slate-200 shadow-sm space-y-3">
|
|
||||||
<h3 className="text-xs font-black text-slate-700 uppercase tracking-widest flex items-center gap-1.5">
|
|
||||||
<FileText className="w-4 h-4 text-[#0F6E56]" />
|
|
||||||
<span>Scanned Passport Scan Document</span>
|
|
||||||
</h3>
|
|
||||||
{selectedItem?.document_type?.toLowerCase().includes('emirates') || selectedItem?.document_type?.toLowerCase().includes('eid') || selectedItem?.document_type?.toLowerCase().includes('id') ? (
|
|
||||||
<div className="relative rounded-xl overflow-hidden border border-slate-200 bg-slate-950 flex flex-col items-center justify-center p-6 text-center aspect-[4/3] max-h-72 shadow-inner">
|
|
||||||
<ShieldCheck className="w-12 h-12 text-emerald-500 mb-3 animate-pulse" />
|
|
||||||
<div className="text-xs font-black text-white uppercase tracking-wider">Raw Scan Deleted (PDPL Law)</div>
|
|
||||||
<div className="text-[10px] text-slate-400 mt-2 font-medium leading-relaxed">
|
|
||||||
To comply strictly with UAE Personal Data Protection Law (PDPL), raw Emirates ID images are immediately destroyed post-OCR. Vetted metadata has been securely preserved.
|
|
||||||
</div>
|
|
||||||
<div className="mt-4 bg-emerald-600 text-white font-black text-[8px] tracking-wider uppercase border-none px-2.5 py-1 rounded">
|
|
||||||
[DELETED_FOR_PDPL_COMPLIANCE]
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
onClick={() => setLightboxImage(selectedItem?.document_images[0])}
|
|
||||||
className="relative rounded-xl overflow-hidden border border-slate-200 bg-slate-100 cursor-zoom-in shadow-inner aspect-[4/3] max-h-72 group"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
src={selectedItem?.document_images[0]}
|
|
||||||
alt="Verification Scan"
|
|
||||||
className="w-full h-full object-cover group-hover:scale-102 transition-transform duration-300"
|
|
||||||
/>
|
|
||||||
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center text-white text-xs font-black uppercase tracking-wider backdrop-blur-[1px]">
|
|
||||||
<ZoomIn className="w-4.5 h-4.5 mr-1" /> Click to Zoom Document
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<p className="text-[10px] text-slate-400 font-bold text-center uppercase tracking-wide">
|
|
||||||
Type: {selectedItem?.document_type}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Warnings Box */}
|
|
||||||
{selectedItem?.warnings?.length > 0 ? (
|
|
||||||
<div className="bg-rose-50/50 border border-rose-100 p-5 rounded-2xl space-y-3 shadow-sm">
|
<div className="bg-rose-50/50 border border-rose-100 p-5 rounded-2xl space-y-3 shadow-sm">
|
||||||
<span className="text-[10px] font-black text-rose-700 uppercase tracking-widest flex items-center gap-1.5">
|
<span className="text-[10px] font-black text-rose-700 uppercase tracking-widest flex items-center gap-1.5">
|
||||||
<AlertTriangle className="w-4 h-4 text-rose-600 animate-bounce" />
|
<AlertTriangle className="w-4 h-4 text-rose-600 animate-bounce" />
|
||||||
<span>Suspicious Document Vetting Warning</span>
|
<span>Verification Warnings</span>
|
||||||
</span>
|
</span>
|
||||||
<ul className="space-y-2 text-xs font-bold text-rose-950">
|
<ul className="space-y-2 text-xs font-bold text-rose-950">
|
||||||
{selectedItem.warnings.map((w, idx) => (
|
{selectedItem.warnings.map((w, idx) => (
|
||||||
@ -378,16 +319,10 @@ export default function Verifications({ verifications, status }) {
|
|||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
|
||||||
<div className="bg-emerald-50/30 border border-emerald-100 p-4 rounded-2xl flex items-center space-x-2 text-emerald-800 text-xs font-bold">
|
|
||||||
<CheckCircle className="w-4 h-4 text-emerald-600" />
|
|
||||||
<span>Vetting passed checks. System matches signature algorithms perfectly.</span>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* OCR Data Extraction Form Column - Supports editing */}
|
{/* OCR Data Extraction Form - Styled Cleanly */}
|
||||||
<div className="bg-white p-5 border border-slate-200 rounded-2xl shadow-sm space-y-6 flex flex-col justify-between">
|
<div className="bg-white p-5 border border-slate-200 rounded-2xl shadow-sm space-y-6">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center justify-between border-b border-slate-100 pb-2">
|
<div className="flex items-center justify-between border-b border-slate-100 pb-2">
|
||||||
<h3 className="text-xs font-black text-slate-700 uppercase tracking-widest">Extracted Text Vitals</h3>
|
<h3 className="text-xs font-black text-slate-700 uppercase tracking-widest">Extracted Text Vitals</h3>
|
||||||
@ -396,18 +331,18 @@ export default function Verifications({ verifications, status }) {
|
|||||||
className="text-xs text-[#0F6E56] hover:text-[#085041] font-black flex items-center gap-1 uppercase tracking-wider text-[10px]"
|
className="text-xs text-[#0F6E56] hover:text-[#085041] font-black flex items-center gap-1 uppercase tracking-wider text-[10px]"
|
||||||
>
|
>
|
||||||
<Edit3 className="w-3.5 h-3.5" />
|
<Edit3 className="w-3.5 h-3.5" />
|
||||||
<span>{isEditingOcr ? 'Cancel Edit' : 'Edit OCR Values'}</span>
|
<span>{isEditingOcr ? 'Cancel' : 'Edit values'}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-4">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
{Object.entries(ocrOverrides).map(([key, value]) => (
|
{Object.entries(ocrOverrides).map(([key, value]) => (
|
||||||
<div key={key} className="space-y-1.5">
|
<div key={key} className="space-y-1.5">
|
||||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest block">{key}</label>
|
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest block">{key}</label>
|
||||||
{isEditingOcr ? (
|
{isEditingOcr ? (
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="w-full bg-slate-50 border border-slate-200 rounded-xl px-3.5 py-2.5 text-xs font-bold focus:bg-white outline-none focus:ring-4 focus:ring-[#0F6E56]/10 focus:border-[#0F6E56] transition-all"
|
className="w-full bg-slate-50 border border-slate-200 rounded-xl px-3.5 py-2.5 text-xs font-bold focus:bg-white outline-none focus:ring-2 focus:ring-[#0F6E56]/10 focus:border-[#0F6E56] transition-all"
|
||||||
value={value}
|
value={value}
|
||||||
onChange={e => {
|
onChange={e => {
|
||||||
const newVal = e.target.value;
|
const newVal = e.target.value;
|
||||||
@ -427,11 +362,11 @@ export default function Verifications({ verifications, status }) {
|
|||||||
{/* Manual Rejection reason input */}
|
{/* Manual Rejection reason input */}
|
||||||
{showRejectionForm ? (
|
{showRejectionForm ? (
|
||||||
<div className="bg-rose-50/30 p-4 border border-rose-100 rounded-xl space-y-3">
|
<div className="bg-rose-50/30 p-4 border border-rose-100 rounded-xl space-y-3">
|
||||||
<label className="text-[10px] font-black text-rose-700 uppercase tracking-widest block">Rejection compliance reason</label>
|
<label className="text-[10px] font-black text-rose-700 uppercase tracking-widest block">Reason for Rejection</label>
|
||||||
<textarea
|
<textarea
|
||||||
rows="2"
|
rows="2"
|
||||||
className="w-full bg-white border border-rose-200 rounded-xl p-3 text-xs font-bold focus:ring-4 focus:ring-rose-500/10 outline-none transition-all"
|
className="w-full bg-white border border-rose-200 rounded-xl p-3 text-xs font-bold focus:ring-4 focus:ring-rose-500/10 outline-none transition-all"
|
||||||
placeholder="Explain to worker what document error occurred (e.g., expiry date mismatch, blurred image)..."
|
placeholder="Explain verification error (e.g. invalid document)..."
|
||||||
value={rejectionReason}
|
value={rejectionReason}
|
||||||
onChange={e => setRejectionReason(e.target.value)}
|
onChange={e => setRejectionReason(e.target.value)}
|
||||||
/>
|
/>
|
||||||
@ -440,11 +375,11 @@ export default function Verifications({ verifications, status }) {
|
|||||||
onClick={() => handleVerifySubmit('reject')}
|
onClick={() => handleVerifySubmit('reject')}
|
||||||
className="px-4 py-2.5 bg-red-600 hover:bg-red-700 text-white rounded-xl text-[10px] font-black uppercase tracking-widest shadow-md transition-colors"
|
className="px-4 py-2.5 bg-red-600 hover:bg-red-700 text-white rounded-xl text-[10px] font-black uppercase tracking-widest shadow-md transition-colors"
|
||||||
>
|
>
|
||||||
Confirm Rejection
|
Confirm
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowRejectionForm(false)}
|
onClick={() => setShowRejectionForm(false)}
|
||||||
className="px-4 py-2.5 bg-slate-200 text-slate-600 hover:bg-slate-300 rounded-xl text-[10px] font-black uppercase tracking-widest transition-colors"
|
className="px-4 py-2.5 bg-slate-200 text-slate-650 hover:bg-slate-350 rounded-xl text-[10px] font-black uppercase tracking-widest transition-colors"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
@ -454,17 +389,17 @@ export default function Verifications({ verifications, status }) {
|
|||||||
<div className="flex gap-3 pt-4 border-t border-slate-100 flex-shrink-0">
|
<div className="flex gap-3 pt-4 border-t border-slate-100 flex-shrink-0">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleVerifySubmit('approve')}
|
onClick={() => handleVerifySubmit('approve')}
|
||||||
className="flex-1 py-3 bg-[#0F6E56] text-white rounded-xl text-[10px] font-black uppercase tracking-widest shadow-lg shadow-teal-700/20 hover:bg-[#085041] transition-all flex items-center justify-center gap-1"
|
className="flex-1 py-3 bg-[#0F6E56] text-white rounded-xl text-[10px] font-black uppercase tracking-widest shadow-lg hover:bg-[#085041] transition-all flex items-center justify-center gap-1"
|
||||||
>
|
>
|
||||||
<CheckCircle className="w-4 h-4" />
|
<CheckCircle className="w-4 h-4" />
|
||||||
<span>Approve Verification</span>
|
<span>Approve</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowRejectionForm(true)}
|
onClick={() => setShowRejectionForm(true)}
|
||||||
className="flex-1 py-3 bg-red-600 text-white rounded-xl text-[10px] font-black uppercase tracking-widest shadow-lg shadow-red-600/10 hover:bg-red-700 transition-all flex items-center justify-center gap-1"
|
className="flex-1 py-3 bg-red-600 text-white rounded-xl text-[10px] font-black uppercase tracking-widest shadow-lg hover:bg-red-700 transition-all flex items-center justify-center gap-1"
|
||||||
>
|
>
|
||||||
<XCircle className="w-4 h-4" />
|
<XCircle className="w-4 h-4" />
|
||||||
<span>Flag Rejection</span>
|
<span>Reject</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -472,26 +407,6 @@ export default function Verifications({ verifications, status }) {
|
|||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|
||||||
{/* Lightbox Zoom */}
|
|
||||||
{lightboxImage && (
|
|
||||||
<div
|
|
||||||
onClick={() => setLightboxImage(null)}
|
|
||||||
className="fixed inset-0 z-50 bg-black/80 backdrop-blur-sm flex items-center justify-center p-4"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="absolute top-6 right-6 text-white/80 hover:text-white p-2 bg-white/10 rounded-full transition-all focus:outline-none"
|
|
||||||
>
|
|
||||||
<X className="w-6 h-6" />
|
|
||||||
</button>
|
|
||||||
<img
|
|
||||||
src={lightboxImage}
|
|
||||||
alt="Zoomed scan"
|
|
||||||
className="max-w-full max-h-[90vh] object-contain rounded-xl shadow-2xl border border-white/20"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</AdminLayout>
|
</AdminLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -77,6 +77,8 @@ export default function Show({ conversation, initialMessages, conversations = []
|
|||||||
setMessages(prev => [...prev, newMsg]);
|
setMessages(prev => [...prev, newMsg]);
|
||||||
setInput('');
|
setInput('');
|
||||||
|
|
||||||
|
const isLookingJobQuery = text.toLowerCase().includes('looking') && text.toLowerCase().includes('job');
|
||||||
|
|
||||||
router.post(`/employer/messages/${conversation.id}/send`, {
|
router.post(`/employer/messages/${conversation.id}/send`, {
|
||||||
text: text
|
text: text
|
||||||
}, {
|
}, {
|
||||||
@ -93,19 +95,24 @@ export default function Show({ conversation, initialMessages, conversations = []
|
|||||||
setIsTyping(true);
|
setIsTyping(true);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setIsTyping(false);
|
setIsTyping(false);
|
||||||
const autoReply = {
|
|
||||||
id: Date.now() + 1,
|
|
||||||
sender: 'worker',
|
|
||||||
text: t('auto_reply_text', "Thank you for your response! I have reviewed your offer proposal and am looking forward to our video interview."),
|
|
||||||
time: t('just_now', 'Just Now')
|
|
||||||
};
|
|
||||||
setMessages(prev => [...prev, autoReply]);
|
|
||||||
|
|
||||||
// Pop up a clear message received notification for the sponsor
|
// Reload conversation to pull actual database messages (including automated worker 'Yes')
|
||||||
|
router.reload({
|
||||||
|
preserveScroll: true,
|
||||||
|
onSuccess: () => {
|
||||||
|
if (isLookingJobQuery) {
|
||||||
|
toast.success(t('candidate_hired_success', "Candidate response: YES! Automatically marked as HIRED!"), {
|
||||||
|
description: t('candidate_hired_desc', "{name} has been successfully added to your Hired candidate pipeline.").replace('{name}', conversation.worker_name),
|
||||||
|
duration: 6000,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
toast.info(t('new_message_from', 'New message from {name}!').replace('{name}', conversation.worker_name), {
|
toast.info(t('new_message_from', 'New message from {name}!').replace('{name}', conversation.worker_name), {
|
||||||
description: autoReply.text,
|
description: t('auto_reply_text', "Thank you for your response! I have reviewed your offer proposal and am looking forward to our video interview."),
|
||||||
duration: 5000
|
duration: 5000
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}, 2000);
|
}, 2000);
|
||||||
}, 1500);
|
}, 1500);
|
||||||
}
|
}
|
||||||
@ -136,6 +143,7 @@ export default function Show({ conversation, initialMessages, conversations = []
|
|||||||
};
|
};
|
||||||
|
|
||||||
const chips = [
|
const chips = [
|
||||||
|
t('chip_looking_job', "Are you looking for a job?"),
|
||||||
t('chip_interview', "Are you available for a video interview today?"),
|
t('chip_interview', "Are you available for a video interview today?"),
|
||||||
t('chip_salary', "What is your final expected salary?"),
|
t('chip_salary', "What is your final expected salary?"),
|
||||||
t('chip_visa', "Can you transfer your visa immediately?")
|
t('chip_visa', "Can you transfer your visa immediately?")
|
||||||
|
|||||||
@ -9,6 +9,7 @@
|
|||||||
use App\Http\Controllers\Api\EmployerMessageController;
|
use App\Http\Controllers\Api\EmployerMessageController;
|
||||||
use App\Http\Controllers\Api\EmployerAnnouncementController;
|
use App\Http\Controllers\Api\EmployerAnnouncementController;
|
||||||
use App\Http\Controllers\Api\EmployerProfileController;
|
use App\Http\Controllers\Api\EmployerProfileController;
|
||||||
|
use App\Http\Controllers\Api\EmployerReviewController;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|--------------------------------------------------------------------------
|
|--------------------------------------------------------------------------
|
||||||
@ -51,6 +52,8 @@
|
|||||||
Route::post('/workers/go-live', [WorkerProfileController::class, 'goLive']);
|
Route::post('/workers/go-live', [WorkerProfileController::class, 'goLive']);
|
||||||
Route::post('/workers/profile/toggle-availability', [WorkerProfileController::class, 'toggleAvailability']);
|
Route::post('/workers/profile/toggle-availability', [WorkerProfileController::class, 'toggleAvailability']);
|
||||||
Route::post('/workers/profile/mark-hired', [WorkerProfileController::class, 'markHired']);
|
Route::post('/workers/profile/mark-hired', [WorkerProfileController::class, 'markHired']);
|
||||||
|
Route::get('/workers/dashboard/views', [WorkerProfileController::class, 'getProfileViews']);
|
||||||
|
Route::get('/workers/dashboard', [WorkerProfileController::class, 'getDashboard']);
|
||||||
|
|
||||||
// Job Offers Management
|
// Job Offers Management
|
||||||
Route::get('/workers/offers', [WorkerProfileController::class, 'getOffers']);
|
Route::get('/workers/offers', [WorkerProfileController::class, 'getOffers']);
|
||||||
@ -84,10 +87,15 @@
|
|||||||
|
|
||||||
// Worker and Candidate Pipeline Management
|
// Worker and Candidate Pipeline Management
|
||||||
Route::get('/employers/workers', [\App\Http\Controllers\Api\EmployerWorkerController::class, 'getWorkers']);
|
Route::get('/employers/workers', [\App\Http\Controllers\Api\EmployerWorkerController::class, 'getWorkers']);
|
||||||
|
Route::get('/employers/workers/{id}', [\App\Http\Controllers\Api\EmployerWorkerController::class, 'getWorkerDetail']);
|
||||||
Route::get('/employers/candidates', [\App\Http\Controllers\Api\EmployerWorkerController::class, 'getCandidates']);
|
Route::get('/employers/candidates', [\App\Http\Controllers\Api\EmployerWorkerController::class, 'getCandidates']);
|
||||||
Route::post('/employers/candidates/{id}/hire', [\App\Http\Controllers\Api\EmployerWorkerController::class, 'hireCandidate']);
|
Route::post('/employers/candidates/{id}/hire', [\App\Http\Controllers\Api\EmployerWorkerController::class, 'hireCandidate']);
|
||||||
Route::post('/employers/candidates/hire', [\App\Http\Controllers\Api\EmployerWorkerController::class, 'hireCandidate']);
|
Route::post('/employers/candidates/hire', [\App\Http\Controllers\Api\EmployerWorkerController::class, 'hireCandidate']);
|
||||||
|
|
||||||
// Payment History Management
|
// Payment History Management
|
||||||
Route::get('/employers/payments', [\App\Http\Controllers\Api\EmployerPaymentController::class, 'getPayments']);
|
Route::get('/employers/payments', [\App\Http\Controllers\Api\EmployerPaymentController::class, 'getPayments']);
|
||||||
|
|
||||||
|
// Review Management
|
||||||
|
Route::post('/employers/reviews', [EmployerReviewController::class, 'addReview']);
|
||||||
|
Route::put('/employers/reviews/{id}', [EmployerReviewController::class, 'editReview']);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -19,7 +19,7 @@ export default defineConfig({
|
|||||||
tailwindcss(),
|
tailwindcss(),
|
||||||
],
|
],
|
||||||
server: {
|
server: {
|
||||||
host: 'localhost',
|
host: '192.168.29.131',
|
||||||
watch: {
|
watch: {
|
||||||
ignored: ['**/storage/framework/views/**'],
|
ignored: ['**/storage/framework/views/**'],
|
||||||
},
|
},
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user