Compare commits
No commits in common. "9af2d5bc4e5bb87f19c620fbaf2693ed7ca04366" and "5ecf7eeb1f96c0e876737793e9d2e6f3da87efe1" have entirely different histories.
9af2d5bc4e
...
5ecf7eeb1f
@ -14,23 +14,48 @@ class AdminExtraController extends Controller
|
||||
*/
|
||||
public function safety()
|
||||
{
|
||||
$reports = DB::table('moderation_reports')->get()->map(function ($report) {
|
||||
return [
|
||||
'id' => $report->id,
|
||||
'type' => $report->type,
|
||||
'reported_user_name' => $report->reported_user_name,
|
||||
'reported_user_role' => $report->reported_user_role,
|
||||
'reported_user_avatar' => $report->reported_user_avatar,
|
||||
'reported_by_name' => $report->reported_by_name,
|
||||
'reported_by_role' => $report->reported_by_role,
|
||||
'reported_by_avatar' => $report->reported_by_avatar,
|
||||
'reason' => $report->reason,
|
||||
'priority' => $report->priority,
|
||||
'status' => $report->status,
|
||||
'description' => $report->description,
|
||||
'reported_at' => date('M d, Y h:i A', strtotime($report->reported_at)),
|
||||
];
|
||||
});
|
||||
$reports = [
|
||||
[
|
||||
'id' => 'REP-101',
|
||||
'reporter' => 'Marina Cleaners (Employer)',
|
||||
'reported_user' => 'Grace Omondi (Worker)',
|
||||
'reason' => 'Profile details mismatch / False advertising',
|
||||
'description' => 'The worker claims to have 4 years experience in childcare, but when interviewed, they stated they only did housekeeping.',
|
||||
'status' => 'Pending Review',
|
||||
'severity' => 'Medium',
|
||||
'created_at' => '2026-05-22 14:30',
|
||||
],
|
||||
[
|
||||
'id' => 'REP-102',
|
||||
'reporter' => 'Siti Aminah (Worker)',
|
||||
'reported_user' => 'Golden Hospitality (Employer)',
|
||||
'reason' => 'Abusive message exchange',
|
||||
'description' => 'Employer used inappropriate language during direct chat after worker declined an weekend offer.',
|
||||
'status' => 'Escalated',
|
||||
'severity' => 'High',
|
||||
'created_at' => '2026-05-21 09:15',
|
||||
],
|
||||
[
|
||||
'id' => 'REP-103',
|
||||
'reporter' => 'System (Auto-Flag)',
|
||||
'reported_user' => 'Amina Diop (Worker)',
|
||||
'reason' => 'Suspicious document signature mismatch',
|
||||
'description' => 'OCR matching detected that the signature and name on the Passport does not match the registration name.',
|
||||
'status' => 'Under Investigation',
|
||||
'severity' => 'High',
|
||||
'created_at' => '2026-05-20 18:45',
|
||||
],
|
||||
[
|
||||
'id' => 'REP-104',
|
||||
'reporter' => 'Hassan Al Hosani (Employer)',
|
||||
'reported_user' => 'Maria Santos (Worker)',
|
||||
'reason' => 'No-show for interview',
|
||||
'description' => 'Worker confirmed three consecutive video interviews but failed to join without notice.',
|
||||
'status' => 'Resolved',
|
||||
'severity' => 'Low',
|
||||
'created_at' => '2026-05-18 11:00',
|
||||
]
|
||||
];
|
||||
|
||||
$rules = [
|
||||
['id' => 1, 'name' => 'IP-Range Duplication Alert', 'trigger' => 'More than 3 worker accounts from the same IP', 'status' => 'Active'],
|
||||
@ -75,24 +100,6 @@ public function resolveSafetyReport(Request $request, $id)
|
||||
'admin_notes' => 'nullable|string'
|
||||
]);
|
||||
|
||||
DB::table('moderation_reports')
|
||||
->where('id', $id)
|
||||
->update([
|
||||
'status' => 'Resolved',
|
||||
'admin_notes' => $request->admin_notes ?: 'Resolved by administrator',
|
||||
'updated_at' => now()
|
||||
]);
|
||||
|
||||
// Dynamically log event
|
||||
DB::table('audit_logs')->insert([
|
||||
'category' => 'admin_action',
|
||||
'user' => 'SuperAdmin (support@marketplace.com)',
|
||||
'action' => 'Resolved Safety Report ' . $id . ' with action: ' . strtoupper($request->action) . '. Notes: ' . ($request->admin_notes ?: 'none'),
|
||||
'ip_address' => $request->ip() ?: '127.0.0.1',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now()
|
||||
]);
|
||||
|
||||
return back()->with('success', "Safety Report {$id} has been resolved successfully with action: " . strtoupper($request->action));
|
||||
}
|
||||
|
||||
@ -101,45 +108,61 @@ public function resolveSafetyReport(Request $request, $id)
|
||||
*/
|
||||
public function disputes()
|
||||
{
|
||||
$tickets = DB::table('disputes')->get()->map(function ($dispute) {
|
||||
// Decode or initialize logs dynamically
|
||||
$logs = [];
|
||||
if ($dispute->chat_logs) {
|
||||
$logs = json_decode($dispute->chat_logs, true) ?: [];
|
||||
} else {
|
||||
$logs = [
|
||||
['sender' => $dispute->party_two_name, 'time' => '10:05 AM', 'message' => "Regarding the dispute on {$dispute->type}: we need to resolve this issue about {$dispute->reason} as soon as possible."],
|
||||
['sender' => $dispute->party_one_name, 'time' => '10:12 AM', 'message' => 'I have already explained my side. Please check the contract agreement details.'],
|
||||
['sender' => $dispute->party_two_name, 'time' => '10:14 AM', 'message' => 'That is not what we agreed upon. Let the admin mediator review the records.']
|
||||
];
|
||||
// Save it back so it becomes persistent in DB immediately!
|
||||
DB::table('disputes')->where('id', $dispute->id)->update([
|
||||
'chat_logs' => json_encode($logs)
|
||||
]);
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $dispute->id,
|
||||
'party_one_name' => $dispute->party_one_name,
|
||||
'party_one_role' => $dispute->party_one_role,
|
||||
'party_one_avatar' => $dispute->party_one_avatar,
|
||||
'party_two_name' => $dispute->party_two_name,
|
||||
'party_two_role' => $dispute->party_two_role,
|
||||
'party_two_avatar' => $dispute->party_two_avatar,
|
||||
'type' => $dispute->type,
|
||||
'reason' => $dispute->reason,
|
||||
'priority' => $dispute->priority,
|
||||
'status' => $dispute->status,
|
||||
'assigned_to' => $dispute->assigned_to,
|
||||
'created_at' => date('M d, Y h:i A', strtotime($dispute->created_at)),
|
||||
'chat_logs' => $logs,
|
||||
'evidence' => [
|
||||
['type' => 'Contract', 'name' => 'hiring_agreement_signed.pdf', 'url' => '#'],
|
||||
['type' => 'ID Proof', 'name' => 'emirates_id_verification.pdf', 'url' => '#']
|
||||
$tickets = [
|
||||
[
|
||||
'id' => 'DISP-701',
|
||||
'employer' => 'Marina Cleaners',
|
||||
'worker' => 'Grace Omondi',
|
||||
'subject' => 'Advance Payment Refund Dispute',
|
||||
'amount_aed' => 499,
|
||||
'status' => 'Open',
|
||||
'created_at' => '2026-05-20',
|
||||
'chat_logs' => [
|
||||
['sender' => 'Employer', 'time' => '10:05 AM', 'message' => 'I paid you the advance of 499 AED for direct transport, but you did not report to work.'],
|
||||
['sender' => 'Worker', 'time' => '10:12 AM', 'message' => 'The agency driver was delayed and did not bring the entry permit paper, so I could not travel.'],
|
||||
['sender' => 'Employer', 'time' => '10:14 AM', 'message' => 'That is your responsibility, I need the refund or immediate attendance.'],
|
||||
['sender' => 'Worker', 'time' => '10:20 AM', 'message' => 'I do not have the money anymore, the transport ticket was already bought and non-refundable.'],
|
||||
],
|
||||
'admin_notes' => $dispute->admin_notes ?: 'Awaiting confirmation of contract terms under UAE law.'
|
||||
];
|
||||
});
|
||||
'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.'
|
||||
]
|
||||
];
|
||||
|
||||
return Inertia::render('Admin/Disputes/Index', [
|
||||
'tickets' => $tickets
|
||||
@ -156,49 +179,6 @@ public function resolveDispute(Request $request, $id)
|
||||
'action_taken' => 'required|string',
|
||||
]);
|
||||
|
||||
$dispute = DB::table('disputes')->where('id', $id)->first();
|
||||
if (!$dispute) {
|
||||
return back()->withErrors(['error' => 'Dispute not found']);
|
||||
}
|
||||
|
||||
// Decode existing logs or set default
|
||||
$logs = [];
|
||||
if ($dispute->chat_logs) {
|
||||
$logs = json_decode($dispute->chat_logs, true) ?: [];
|
||||
} else {
|
||||
$logs = [
|
||||
['sender' => $dispute->party_two_name, 'time' => '10:05 AM', 'message' => "Regarding the dispute on {$dispute->type}: we need to resolve this issue about {$dispute->reason} as soon as possible."],
|
||||
['sender' => $dispute->party_one_name, 'time' => '10:12 AM', 'message' => 'I have already explained my side. Please check the contract agreement details.'],
|
||||
['sender' => $dispute->party_two_name, 'time' => '10:14 AM', 'message' => 'That is not what we agreed upon. Let the admin mediator review the records.']
|
||||
];
|
||||
}
|
||||
|
||||
// Add resolution details
|
||||
$logs[] = [
|
||||
'sender' => 'Mediator (Admin)',
|
||||
'time' => date('h:i A'),
|
||||
'message' => '⚠️ RESOLVED & CLOSED CASE: ' . $request->resolution
|
||||
];
|
||||
|
||||
DB::table('disputes')
|
||||
->where('id', $id)
|
||||
->update([
|
||||
'status' => 'Resolved',
|
||||
'admin_notes' => $dispute->admin_notes ? ($dispute->admin_notes . "\n• Resolution: " . $request->resolution) : ("• Resolution: " . $request->resolution),
|
||||
'chat_logs' => json_encode($logs),
|
||||
'updated_at' => now()
|
||||
]);
|
||||
|
||||
// Dynamically log event
|
||||
DB::table('audit_logs')->insert([
|
||||
'category' => 'admin_action',
|
||||
'user' => 'SuperAdmin (support@marketplace.com)',
|
||||
'action' => 'Resolved Dispute Ticket ' . $id . ' with resolution: ' . $request->resolution . ' (' . strtoupper($request->action_taken) . ')',
|
||||
'ip_address' => $request->ip() ?: '127.0.0.1',
|
||||
'created_at' => now(),
|
||||
'updated_at' => now()
|
||||
]);
|
||||
|
||||
return back()->with('success', "Dispute ticket {$id} has been resolved successfully. Resolution: " . strtoupper($request->action_taken));
|
||||
}
|
||||
|
||||
@ -211,54 +191,9 @@ public function addDisputeNote(Request $request, $id)
|
||||
'note' => 'required|string'
|
||||
]);
|
||||
|
||||
// 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}.");
|
||||
return back()->with('success', "Internal admin note added to Dispute ticket {$id}.");
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Notifications & Campaign Management
|
||||
*/
|
||||
@ -356,68 +291,51 @@ public function refundPayment(Request $request, $id)
|
||||
}
|
||||
|
||||
/**
|
||||
* Interactive Reports Hub
|
||||
* Interactive Reports & Analytics Hub
|
||||
*/
|
||||
public function analytics()
|
||||
{
|
||||
// 1. Worker Stats
|
||||
$workerStats = [
|
||||
'total' => DB::table('workers')->count(),
|
||||
'verified' => DB::table('workers')->where('verified', 1)->count(),
|
||||
'active' => DB::table('workers')->where('status', 'active')->count(),
|
||||
'inactive' => DB::table('workers')->where('status', 'inactive')->count(),
|
||||
// Supply rich visual charts metadata
|
||||
$userGrowth = [
|
||||
['month' => 'Dec 25', 'workers' => 850, 'employers' => 210],
|
||||
['month' => 'Jan 26', 'workers' => 990, 'employers' => 240],
|
||||
['month' => 'Feb 26', 'workers' => 1100, 'employers' => 280],
|
||||
['month' => 'Mar 26', 'workers' => 1250, 'employers' => 310],
|
||||
['month' => 'Apr 26', 'workers' => 1350, 'employers' => 345],
|
||||
['month' => 'May 26', 'workers' => 1420, 'employers' => 380]
|
||||
];
|
||||
|
||||
// 2. Sponsor Stats
|
||||
$sponsorStats = [
|
||||
'total' => DB::table('sponsors')->count(),
|
||||
'verified' => DB::table('sponsors')->where('is_verified', 1)->count(),
|
||||
'active_subscribers' => DB::table('sponsors')->where('subscription_status', 'active')->count(),
|
||||
'unpaid' => DB::table('sponsors')->where('payment_status', 'unpaid')->count(),
|
||||
$revenueBreakdown = [
|
||||
['month' => 'Dec 25', 'basic' => 15000, 'premium' => 18000, 'vip' => 12000],
|
||||
['month' => 'Jan 26', 'basic' => 16500, 'premium' => 19500, 'vip' => 13500],
|
||||
['month' => 'Feb 26', 'basic' => 18000, 'premium' => 22000, 'vip' => 15000],
|
||||
['month' => 'Mar 26', 'basic' => 19200, 'premium' => 25000, 'vip' => 18500],
|
||||
['month' => 'Apr 26', 'basic' => 21000, 'premium' => 29000, 'vip' => 22000],
|
||||
['month' => 'May 26', 'basic' => 22500, 'premium' => 32000, 'vip' => 24000]
|
||||
];
|
||||
|
||||
// 3. Dispute & Safety Stats
|
||||
$disputeStats = [
|
||||
'total' => DB::table('disputes')->count(),
|
||||
'open' => DB::table('disputes')->where('status', 'Open')->count(),
|
||||
'under_review' => DB::table('disputes')->where('status', 'Under Review')->count(),
|
||||
'resolved' => DB::table('disputes')->where('status', 'Resolved')->count(),
|
||||
$retentionRates = [
|
||||
['cohort' => 'Month 1', 'rate' => 92],
|
||||
['cohort' => 'Month 2', 'rate' => 84],
|
||||
['cohort' => 'Month 3', 'rate' => 79],
|
||||
['cohort' => 'Month 4', 'rate' => 74],
|
||||
['cohort' => 'Month 5', 'rate' => 71],
|
||||
['cohort' => 'Month 6', 'rate' => 68],
|
||||
];
|
||||
|
||||
$safetyStats = [
|
||||
'total' => DB::table('moderation_reports')->count(),
|
||||
'pending' => DB::table('moderation_reports')->where('status', 'Pending')->count(),
|
||||
'resolved' => DB::table('moderation_reports')->where('status', 'Resolved')->count(),
|
||||
$hiringFunnel = [
|
||||
['stage' => 'Profiles Browsed', 'count' => 12500],
|
||||
['stage' => 'Chats Initiated', 'count' => 4820],
|
||||
['stage' => 'Interviews Held', 'count' => 1860],
|
||||
['stage' => 'Offers Extended', 'count' => 920],
|
||||
['stage' => 'Workers Hired', 'count' => 610],
|
||||
];
|
||||
|
||||
// 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', [
|
||||
'worker_stats' => $workerStats,
|
||||
'sponsor_stats' => $sponsorStats,
|
||||
'dispute_stats' => $disputeStats,
|
||||
'safety_stats' => $safetyStats,
|
||||
'payments' => $payments,
|
||||
'total_revenue' => (float)$totalRevenue,
|
||||
'user_growth' => $userGrowth,
|
||||
'revenue_breakdown' => $revenueBreakdown,
|
||||
'retention_rates' => $retentionRates,
|
||||
'hiring_funnel' => $hiringFunnel
|
||||
]);
|
||||
}
|
||||
|
||||
@ -429,36 +347,98 @@ public function auditLogs(Request $request)
|
||||
$searchTerm = $request->input('search', '');
|
||||
$category = $request->input('category', 'all');
|
||||
|
||||
$query = DB::table('audit_logs');
|
||||
$allLogs = [
|
||||
// Admin Action Logs
|
||||
[
|
||||
'id' => 'LOG-991',
|
||||
'category' => 'admin_action',
|
||||
'user' => 'SuperAdmin (support@marketplace.com)',
|
||||
'action' => 'Suspended Employer account Golden Hospitality due to dispute findings',
|
||||
'ip_address' => '192.168.1.1',
|
||||
'timestamp' => '2026-05-23 16:45',
|
||||
],
|
||||
[
|
||||
'id' => 'LOG-992',
|
||||
'category' => 'admin_action',
|
||||
'user' => 'Admin Vetting Officer',
|
||||
'action' => 'Manually approved Worker Leila Bekri passport verification overriding OCR confidence',
|
||||
'ip_address' => '192.168.1.5',
|
||||
'timestamp' => '2026-05-23 15:20',
|
||||
],
|
||||
// Verification Logs
|
||||
[
|
||||
'id' => 'LOG-993',
|
||||
'category' => 'verification',
|
||||
'user' => 'System OCR engine',
|
||||
'action' => 'Auto-OCR Passport verification SUCCESS for worker Fatima Zahra (Morocco) • Confidence 98%',
|
||||
'ip_address' => 'Auto-Trigger',
|
||||
'timestamp' => '2026-05-23 14:15',
|
||||
],
|
||||
[
|
||||
'id' => 'LOG-994',
|
||||
'category' => 'verification',
|
||||
'user' => 'System OCR engine',
|
||||
'action' => 'Auto-OCR Passport FLAG suspicious document detected for worker Grace Omondi',
|
||||
'ip_address' => 'Auto-Trigger',
|
||||
'timestamp' => '2026-05-23 13:42',
|
||||
],
|
||||
// Payment Logs
|
||||
[
|
||||
'id' => 'LOG-995',
|
||||
'category' => 'payment',
|
||||
'user' => 'Stripe Webhook',
|
||||
'action' => 'Subscription PAYMENT charge of AED 499 COMPLETED. Employer: Al Barari Real Estate',
|
||||
'ip_address' => 'Stripe Gateway',
|
||||
'timestamp' => '2026-05-23 10:30',
|
||||
],
|
||||
[
|
||||
'id' => 'LOG-996',
|
||||
'category' => 'payment',
|
||||
'user' => 'Stripe Webhook',
|
||||
'action' => 'Subscription PAYMENT charge of AED 199 FAILED (Insufficient funds). Employer: Dubai Mall Services',
|
||||
'ip_address' => 'Stripe Gateway',
|
||||
'timestamp' => '2026-05-22 17:10',
|
||||
],
|
||||
// Dispute Logs
|
||||
[
|
||||
'id' => 'LOG-997',
|
||||
'category' => 'dispute',
|
||||
'user' => 'SuperAdmin (support@marketplace.com)',
|
||||
'action' => 'Opened Dispute Ticket DISP-701 regarding Advance Refund between Marina Cleaners & Grace Omondi',
|
||||
'ip_address' => '192.168.1.1',
|
||||
'timestamp' => '2026-05-22 14:45',
|
||||
],
|
||||
// User Activity Logs
|
||||
[
|
||||
'id' => 'LOG-998',
|
||||
'category' => 'user_activity',
|
||||
'user' => 'Maria Santos (Worker)',
|
||||
'action' => 'Logged in successfully via iOS Mobile App',
|
||||
'ip_address' => '94.200.12.83 (Dubai)',
|
||||
'timestamp' => '2026-05-23 17:02',
|
||||
],
|
||||
[
|
||||
'id' => 'LOG-999',
|
||||
'category' => 'user_activity',
|
||||
'user' => 'Marina Cleaners (Employer)',
|
||||
'action' => 'Browsed 14 worker candidate profiles & saved 3 shortlists',
|
||||
'ip_address' => '91.74.203.11 (Abu Dhabi)',
|
||||
'timestamp' => '2026-05-23 16:58',
|
||||
]
|
||||
];
|
||||
|
||||
if ($category !== 'all') {
|
||||
$query->where('category', $category);
|
||||
}
|
||||
|
||||
if (!empty($searchTerm)) {
|
||||
$query->where(function($q) use ($searchTerm) {
|
||||
$q->where('user', 'like', '%' . $searchTerm . '%')
|
||||
->orWhere('action', 'like', '%' . $searchTerm . '%')
|
||||
->orWhere('id', 'like', '%' . $searchTerm . '%');
|
||||
});
|
||||
}
|
||||
|
||||
$logs = $query->orderBy('created_at', 'desc')
|
||||
->orderBy('id', 'desc')
|
||||
->get()
|
||||
->map(function ($log) {
|
||||
return [
|
||||
'id' => 'LOG-' . str_pad($log->id, 5, '0', STR_PAD_LEFT),
|
||||
'category' => $log->category,
|
||||
'user' => $log->user,
|
||||
'action' => $log->action,
|
||||
'ip_address' => $log->ip_address ?: 'Unknown',
|
||||
'timestamp' => date('Y-m-d H:i', strtotime($log->created_at)),
|
||||
];
|
||||
});
|
||||
// Filter search & category
|
||||
$filtered = array_filter($allLogs, function ($log) use ($searchTerm, $category) {
|
||||
$catMatches = $category === 'all' || $log['category'] === $category;
|
||||
$searchMatches = empty($searchTerm) ||
|
||||
stripos($log['user'], $searchTerm) !== false ||
|
||||
stripos($log['action'], $searchTerm) !== false ||
|
||||
stripos($log['id'], $searchTerm) !== false;
|
||||
return $catMatches && $searchMatches;
|
||||
});
|
||||
|
||||
return Inertia::render('Admin/AuditLogs/Index', [
|
||||
'logs' => $logs,
|
||||
'logs' => array_values($filtered),
|
||||
'search' => $searchTerm,
|
||||
'category' => $category,
|
||||
]);
|
||||
|
||||
@ -7,69 +7,12 @@
|
||||
use App\Models\Message;
|
||||
use App\Models\Worker;
|
||||
use App\Models\User;
|
||||
use App\Models\JobOffer;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
class EmployerMessageController extends Controller
|
||||
{
|
||||
public static function processWorkerResponse($conv, $worker, $text)
|
||||
{
|
||||
$replyText = strtolower(trim($text));
|
||||
|
||||
if ($replyText === 'yes' || $replyText === 'no') {
|
||||
// Find the last message sent by the employer in this conversation (excluding the current worker message)
|
||||
$lastEmployerMessage = Message::where('conversation_id', $conv->id)
|
||||
->where('sender_type', 'employer')
|
||||
->latest()
|
||||
->first();
|
||||
|
||||
if ($lastEmployerMessage) {
|
||||
$questionText = strtolower($lastEmployerMessage->text);
|
||||
$isLookingJobQuestion = str_contains($questionText, 'looking job') ||
|
||||
str_contains($questionText, 'looking for a job') ||
|
||||
str_contains($questionText, 'looking for job') ||
|
||||
str_contains($questionText, 'are you looking');
|
||||
|
||||
if ($isLookingJobQuestion) {
|
||||
if ($replyText === 'yes') {
|
||||
// S6 Outcome: Update status as Hired
|
||||
$worker->update([
|
||||
'status' => 'Hired',
|
||||
'availability' => 'Hired'
|
||||
]);
|
||||
|
||||
// Accept existing direct offer or application
|
||||
$offer = JobOffer::where('employer_id', $conv->employer_id)
|
||||
->where('worker_id', $worker->id)
|
||||
->first();
|
||||
|
||||
if ($offer) {
|
||||
$offer->update(['status' => 'accepted']);
|
||||
} else {
|
||||
JobOffer::create([
|
||||
'employer_id' => $conv->employer_id,
|
||||
'worker_id' => $worker->id,
|
||||
'work_date' => now()->format('Y-m-d'),
|
||||
'location' => 'Dubai',
|
||||
'salary' => $worker->salary ?: 2000,
|
||||
'notes' => 'Hired via chat agreement.',
|
||||
'status' => 'accepted',
|
||||
]);
|
||||
}
|
||||
} else if ($replyText === 'no') {
|
||||
// S5 Outcome: Update status as Hidden (Auto-Hidden from search)
|
||||
$worker->update([
|
||||
'status' => 'hidden',
|
||||
'availability' => 'Not Available'
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all conversations for the authorized employer.
|
||||
*
|
||||
@ -166,8 +109,6 @@ public function getMessages(Request $request, $id)
|
||||
'time' => $msg->created_at->format('g:i A') . ($msg->created_at->isToday() ? ' Today' : ' ' . $msg->created_at->format('M d')),
|
||||
'read_at' => $msg->read_at ? $msg->read_at->toISOString() : null,
|
||||
'created_at' => $msg->created_at->toISOString(),
|
||||
'attachment_url' => $msg->attachment_path ? asset('storage/' . $msg->attachment_path) : null,
|
||||
'attachment_type' => $msg->attachment_type,
|
||||
];
|
||||
});
|
||||
|
||||
@ -211,8 +152,7 @@ public function sendMessage(Request $request, $id)
|
||||
$employer = $request->attributes->get('employer');
|
||||
|
||||
$validator = Validator::make($request->all(), [
|
||||
'text' => 'required_without:file|string|max:1000|nullable',
|
||||
'file' => 'nullable|file|mimes:jpg,jpeg,png,pdf,mp3,wav,m4a,ogg,webm,mp4,aac,3gp,amr|max:10240', // 10MB limit
|
||||
'text' => 'required|string|max:1000',
|
||||
]);
|
||||
|
||||
if ($validator->fails()) {
|
||||
@ -235,62 +175,17 @@ public function sendMessage(Request $request, $id)
|
||||
], 404);
|
||||
}
|
||||
|
||||
$attachmentPath = null;
|
||||
$attachmentType = null;
|
||||
|
||||
if ($request->hasFile('file')) {
|
||||
$file = $request->file('file');
|
||||
$attachmentPath = $file->store('chat_attachments', 'public');
|
||||
|
||||
$mime = $file->getMimeType();
|
||||
if (str_starts_with($mime, 'image/')) {
|
||||
$attachmentType = 'image';
|
||||
} elseif (str_starts_with($mime, 'audio/')) {
|
||||
$attachmentType = 'voice';
|
||||
} else {
|
||||
$attachmentType = 'document';
|
||||
}
|
||||
}
|
||||
|
||||
$message = null;
|
||||
DB::transaction(function () use ($conv, $employer, $request, $attachmentPath, $attachmentType, &$message) {
|
||||
DB::transaction(function () use ($conv, $employer, $request, &$message) {
|
||||
$message = Message::create([
|
||||
'conversation_id' => $conv->id,
|
||||
'sender_type' => 'employer',
|
||||
'sender_id' => $employer->id,
|
||||
'text' => $request->text,
|
||||
'attachment_path' => $attachmentPath,
|
||||
'attachment_type' => $attachmentType,
|
||||
]);
|
||||
|
||||
// Touch conversation updated_at for sorting
|
||||
$conv->touch();
|
||||
|
||||
// Check if employer sent predefined message "are you looking job?"
|
||||
$textLower = strtolower($request->text);
|
||||
if (
|
||||
str_contains($textLower, 'looking job') ||
|
||||
str_contains($textLower, 'looking for a job') ||
|
||||
str_contains($textLower, 'looking for job') ||
|
||||
str_contains($textLower, 'are you looking')
|
||||
) {
|
||||
// Automatically simulate worker response 'Yes' (triggers the S6 hired flow)
|
||||
$workerResponseText = 'Yes';
|
||||
|
||||
// Create the message in database
|
||||
Message::create([
|
||||
'conversation_id' => $conv->id,
|
||||
'sender_type' => 'worker',
|
||||
'sender_id' => $conv->worker_id,
|
||||
'text' => $workerResponseText,
|
||||
]);
|
||||
|
||||
// Process the worker response to update status to Hired!
|
||||
$worker = Worker::find($conv->worker_id);
|
||||
if ($worker) {
|
||||
self::processWorkerResponse($conv, $worker, $workerResponseText);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
@ -303,8 +198,6 @@ public function sendMessage(Request $request, $id)
|
||||
'text' => $message->text,
|
||||
'time' => $message->created_at->format('g:i A') . ' Today',
|
||||
'created_at' => $message->created_at->toISOString(),
|
||||
'attachment_url' => $message->attachment_path ? asset('storage/' . $message->attachment_path) : null,
|
||||
'attachment_type' => $message->attachment_type,
|
||||
]
|
||||
]
|
||||
], 201);
|
||||
|
||||
@ -1,86 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use App\Models\User;
|
||||
use App\Models\Payment;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class EmployerPaymentController extends Controller
|
||||
{
|
||||
/**
|
||||
* GET /api/employers/payments
|
||||
* Get the payment transaction history for the authenticated employer.
|
||||
*/
|
||||
public function getPayments(Request $request)
|
||||
{
|
||||
/** @var User $employer */
|
||||
$employer = $request->attributes->get('employer');
|
||||
|
||||
try {
|
||||
$employerId = $employer->id;
|
||||
|
||||
// Auto-seed a couple of payments if none exist for a richer UX
|
||||
$paymentsCount = Payment::where('user_id', $employerId)->count();
|
||||
if ($paymentsCount === 0) {
|
||||
// Determine their plan
|
||||
$subscription = DB::table('subscriptions')->where('user_id', $employerId)->first();
|
||||
$planName = $subscription ? (ucfirst($subscription->plan_id) . ' Pass Subscription') : 'Premium Sponsor Pass Subscription';
|
||||
$planAmount = $subscription ? $subscription->amount_aed : 199.00;
|
||||
|
||||
Payment::create([
|
||||
'user_id' => $employerId,
|
||||
'amount' => $planAmount,
|
||||
'currency' => 'AED',
|
||||
'description' => $planName,
|
||||
'status' => 'success',
|
||||
'created_at' => now()->subDays(15),
|
||||
'updated_at' => now()->subDays(15),
|
||||
]);
|
||||
|
||||
Payment::create([
|
||||
'user_id' => $employerId,
|
||||
'amount' => 49.00,
|
||||
'currency' => 'AED',
|
||||
'description' => 'OCR Document Vetting Fee',
|
||||
'status' => 'success',
|
||||
'created_at' => now()->subDays(5),
|
||||
'updated_at' => now()->subDays(5),
|
||||
]);
|
||||
}
|
||||
|
||||
$payments = Payment::where('user_id', $employerId)
|
||||
->latest()
|
||||
->get()
|
||||
->map(function ($payment) {
|
||||
return [
|
||||
'id' => $payment->id,
|
||||
'amount' => (float)$payment->amount,
|
||||
'currency' => $payment->currency,
|
||||
'description' => $payment->description,
|
||||
'status' => $payment->status,
|
||||
'date' => $payment->created_at->format('Y-m-d H:i:s'),
|
||||
'formatted_date' => $payment->created_at->format('M d, Y'),
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'payments' => $payments
|
||||
]
|
||||
], 200);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
logger()->error('Mobile API Employer Get Payments Failure: ' . $e->getMessage());
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'An error occurred while fetching the payment history.',
|
||||
'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error'
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,144 +0,0 @@
|
||||
<?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,8 +11,6 @@
|
||||
use App\Models\JobOffer;
|
||||
use App\Models\JobApplication;
|
||||
use App\Models\JobPost;
|
||||
use App\Models\Review;
|
||||
use App\Models\ProfileView;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
@ -217,10 +215,13 @@ public function getCandidates(Request $request)
|
||||
// Merge and sort
|
||||
$mergedCandidates = array_merge($selectedWorkers, $directWorkers);
|
||||
|
||||
// Only display Hired candidates in the candidates pipeline (exclude active states)
|
||||
$mergedCandidates = array_values(array_filter($mergedCandidates, function ($c) {
|
||||
return $c && $c['status'] === 'Hired';
|
||||
}));
|
||||
// Optional status filtering
|
||||
if ($request->filled('status')) {
|
||||
$filterStatus = $request->status;
|
||||
$mergedCandidates = array_values(array_filter($mergedCandidates, function ($c) use ($filterStatus) {
|
||||
return strcasecmp($c['status'], $filterStatus) === 0;
|
||||
}));
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
@ -358,173 +359,4 @@ public function hireCandidate(Request $request, $id = null)
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 4. GET /api/employers/workers/{id}
|
||||
* Get detailed worker profile, mirroring the web detail view.
|
||||
*/
|
||||
public function getWorkerDetail(Request $request, $id)
|
||||
{
|
||||
/** @var User $employer */
|
||||
$employer = $request->attributes->get('employer');
|
||||
|
||||
try {
|
||||
$w = Worker::with(['category', 'skills', 'documents'])->find($id);
|
||||
|
||||
if (!$w) {
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'Worker profile not found.'
|
||||
], 404);
|
||||
}
|
||||
|
||||
// Record employer profile view (Requirement 3)
|
||||
if ($employer) {
|
||||
ProfileView::updateOrCreate(
|
||||
['employer_id' => $employer->id, 'worker_id' => $w->id],
|
||||
['updated_at' => now()]
|
||||
);
|
||||
}
|
||||
|
||||
// Map languages
|
||||
$langs = ($w->id % 2 === 0) ? ['English', 'Arabic'] : (($w->id % 3 === 0) ? ['English', 'Tagalog'] : ['English', 'Hindi', 'Arabic']);
|
||||
|
||||
// Preferred job types
|
||||
$jobTypes = ['full-time', 'part-time', 'live-in', 'live-out'];
|
||||
$preferredJobType = $jobTypes[$w->id % 4];
|
||||
|
||||
// Availability status
|
||||
$availabilityStatus = ucfirst($w->status === 'active' ? 'Active' : ($w->status === 'hidden' ? 'Hidden' : ($w->status === 'Hired' ? 'Hired' : 'Active')));
|
||||
|
||||
// Emirates ID status
|
||||
$emiratesIdStatus = $w->verified ? 'Emirates ID Verified' : 'EID Vetting Pending';
|
||||
|
||||
// Skills mapping
|
||||
$skillsList = ['cooking', 'driving', 'childcare', 'cleaning', 'elderly care', 'gardening'];
|
||||
$mappedSkills = [
|
||||
$skillsList[$w->id % 6],
|
||||
$skillsList[($w->id + 2) % 6]
|
||||
];
|
||||
|
||||
// Visa status
|
||||
$visaStatusesList = ['Residence Visa', 'Tourist Visa', 'Employment Visa', 'Cancelled Visa', 'Own Visa'];
|
||||
$visaStatus = $visaStatusesList[$w->id % 5];
|
||||
|
||||
// Profile photo
|
||||
$photos = [
|
||||
'https://images.unsplash.com/photo-1573496359142-b8d87734a5a2?auto=format&fit=crop&q=80&w=200',
|
||||
'https://images.unsplash.com/photo-1534528741775-53994a69daeb?auto=format&fit=crop&q=80&w=200',
|
||||
'https://images.unsplash.com/photo-1544005313-94ddf0286df2?auto=format&fit=crop&q=80&w=200',
|
||||
null
|
||||
];
|
||||
$photo = $photos[$w->id % 4];
|
||||
|
||||
// Fetch dynamic database reviews (Requirement 1)
|
||||
$dbReviews = Review::where('worker_id', $w->id)->with('employer')->latest()->get();
|
||||
$reviews = $dbReviews->map(function ($rev) {
|
||||
return [
|
||||
'id' => $rev->id,
|
||||
'employer_name' => $rev->employer->name ?? 'Employer',
|
||||
'rating' => $rev->rating,
|
||||
'date' => $rev->created_at->format('M d, Y'),
|
||||
'comment' => $rev->comment,
|
||||
];
|
||||
})->toArray();
|
||||
|
||||
$reviewsCount = count($reviews);
|
||||
if ($reviewsCount > 0) {
|
||||
$rating = round($dbReviews->avg('rating'), 1);
|
||||
} else {
|
||||
$rating = 4.0 + (($w->id * 3) % 10) / 10.0;
|
||||
$reviewsCount = ($w->id * 4) % 20 + 2;
|
||||
$reviews = [
|
||||
[
|
||||
'id' => 1,
|
||||
'employer_name' => 'Fatima Al Mansoori',
|
||||
'rating' => 5,
|
||||
'date' => 'May 10, 2026',
|
||||
'comment' => 'Extremely reliable and respectful. Professional work and great communication.',
|
||||
],
|
||||
[
|
||||
'id' => 2,
|
||||
'employer_name' => 'Michael Harrison',
|
||||
'rating' => 4,
|
||||
'date' => 'Mar 24, 2026',
|
||||
'comment' => 'Very punctual, did exactly what was expected. Highly recommend.',
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
// Similar workers matching web view
|
||||
$simDb = Worker::with('category')
|
||||
->where('id', '!=', $w->id)
|
||||
->where('status', '!=', 'Hired')
|
||||
->where('status', '!=', 'hidden')
|
||||
->limit(3)
|
||||
->get();
|
||||
|
||||
$similarWorkers = $simDb->map(function($sw) {
|
||||
$availabilityStatus = ucfirst($sw->status === 'active' ? 'Active' : ($sw->status === 'hidden' ? 'Hidden' : ($sw->status === 'Hired' ? 'Hired' : 'Active')));
|
||||
|
||||
return [
|
||||
'id' => $sw->id,
|
||||
'name' => $sw->name,
|
||||
'nationality' => $sw->nationality,
|
||||
'category' => $sw->category ? $sw->category->name : 'Helper',
|
||||
'salary' => (int)$sw->salary,
|
||||
'rating' => 4.7,
|
||||
'verified' => (bool)$sw->verified,
|
||||
'availability_status' => $availabilityStatus,
|
||||
];
|
||||
})->toArray();
|
||||
|
||||
// Check if there is an existing conversation between this employer and this worker
|
||||
$conversation = \App\Models\Conversation::where('employer_id', $employer->id)
|
||||
->where('worker_id', $w->id)
|
||||
->first();
|
||||
|
||||
$workerProfile = [
|
||||
'id' => $w->id,
|
||||
'name' => $w->name,
|
||||
'nationality' => $w->nationality,
|
||||
'photo' => $photo,
|
||||
'emirates_id_status' => $emiratesIdStatus,
|
||||
'category' => $w->category ? $w->category->name : 'Domestic Worker',
|
||||
'skills' => $mappedSkills,
|
||||
'availability_status' => $availabilityStatus,
|
||||
'visa_status' => $visaStatus,
|
||||
'experience' => $w->experience,
|
||||
'experience_years' => 5,
|
||||
'salary' => (int)$w->salary,
|
||||
'religion' => $w->religion,
|
||||
'languages' => $langs,
|
||||
'age' => $w->age,
|
||||
'verified' => (bool)$w->verified,
|
||||
'preferred_job_type' => $preferredJobType,
|
||||
'bio' => $w->bio,
|
||||
'rating' => $rating,
|
||||
'reviews_count' => $reviewsCount,
|
||||
'reviews' => $reviews,
|
||||
'similar_workers' => $similarWorkers,
|
||||
'conversation_id' => $conversation ? $conversation->id : null,
|
||||
];
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'worker' => $workerProfile
|
||||
]
|
||||
], 200);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
logger()->error('Mobile API Employer Get Worker Detail Failure: ' . $e->getMessage());
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'An error occurred while fetching the worker profile.',
|
||||
'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error'
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -6,69 +6,12 @@
|
||||
use App\Models\Conversation;
|
||||
use App\Models\Message;
|
||||
use App\Models\Worker;
|
||||
use App\Models\JobOffer;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
class WorkerMessageController extends Controller
|
||||
{
|
||||
public static function processWorkerResponse($conv, $worker, $text)
|
||||
{
|
||||
$replyText = strtolower(trim($text));
|
||||
|
||||
if ($replyText === 'yes' || $replyText === 'no') {
|
||||
// Find the last message sent by the employer in this conversation (excluding the current worker message)
|
||||
$lastEmployerMessage = Message::where('conversation_id', $conv->id)
|
||||
->where('sender_type', 'employer')
|
||||
->latest()
|
||||
->first();
|
||||
|
||||
if ($lastEmployerMessage) {
|
||||
$questionText = strtolower($lastEmployerMessage->text);
|
||||
$isLookingJobQuestion = str_contains($questionText, 'looking job') ||
|
||||
str_contains($questionText, 'looking for a job') ||
|
||||
str_contains($questionText, 'looking for job') ||
|
||||
str_contains($questionText, 'are you looking');
|
||||
|
||||
if ($isLookingJobQuestion) {
|
||||
if ($replyText === 'yes') {
|
||||
// S6 Outcome: Update status as Hired
|
||||
$worker->update([
|
||||
'status' => 'Hired',
|
||||
'availability' => 'Hired'
|
||||
]);
|
||||
|
||||
// Accept existing direct offer or application
|
||||
$offer = JobOffer::where('employer_id', $conv->employer_id)
|
||||
->where('worker_id', $worker->id)
|
||||
->first();
|
||||
|
||||
if ($offer) {
|
||||
$offer->update(['status' => 'accepted']);
|
||||
} else {
|
||||
JobOffer::create([
|
||||
'employer_id' => $conv->employer_id,
|
||||
'worker_id' => $worker->id,
|
||||
'work_date' => now()->format('Y-m-d'),
|
||||
'location' => 'Dubai',
|
||||
'salary' => $worker->salary ?: 2000,
|
||||
'notes' => 'Hired via chat agreement.',
|
||||
'status' => 'accepted',
|
||||
]);
|
||||
}
|
||||
} else if ($replyText === 'no') {
|
||||
// S5 Outcome: Update status as Hidden (Auto-Hidden from search)
|
||||
$worker->update([
|
||||
'status' => 'hidden',
|
||||
'availability' => 'Not Available'
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all conversations for the authorized worker.
|
||||
*
|
||||
@ -238,9 +181,6 @@ public function sendMessage(Request $request, $id)
|
||||
|
||||
// Touch conversation updated_at for sorting
|
||||
$conv->touch();
|
||||
|
||||
// Process the worker response to update status to Hired or Hidden
|
||||
self::processWorkerResponse($conv, $worker, $request->text);
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
|
||||
@ -6,9 +6,6 @@
|
||||
use App\Models\JobOffer;
|
||||
use App\Models\Worker;
|
||||
use App\Models\WorkerDocument;
|
||||
use App\Models\ProfileView;
|
||||
use App\Models\EmployerProfile;
|
||||
use App\Models\Announcement;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
@ -434,140 +431,4 @@ public function respondToOffer(Request $request, $id)
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get profile view statistics and employer listing for the worker dashboard.
|
||||
* GET /api/workers/dashboard/views
|
||||
*/
|
||||
public function getProfileViews(Request $request)
|
||||
{
|
||||
/** @var Worker $worker */
|
||||
$worker = $request->attributes->get('worker');
|
||||
|
||||
try {
|
||||
// Get views count
|
||||
$viewsCount = ProfileView::where('worker_id', $worker->id)->count();
|
||||
|
||||
// Get list of employers/sponsors who viewed, with their details
|
||||
$views = ProfileView::where('worker_id', $worker->id)
|
||||
->with('employer')
|
||||
->latest('updated_at')
|
||||
->get();
|
||||
|
||||
$list = $views->map(function ($view) {
|
||||
$emp = $view->employer;
|
||||
|
||||
$empProfile = null;
|
||||
if ($emp) {
|
||||
$empProfile = EmployerProfile::where('user_id', $emp->id)->first();
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $view->id,
|
||||
'employer_id' => $view->employer_id,
|
||||
'employer_name' => $emp->name ?? 'Employer/Sponsor',
|
||||
'company_name' => $empProfile->company_name ?? 'Private Sponsor',
|
||||
'nationality' => $empProfile->nationality ?? 'UAE',
|
||||
'city' => $empProfile->city ?? 'Dubai',
|
||||
'viewed_at' => $view->updated_at->toIso8601String(),
|
||||
'viewed_at_formatted' => $view->updated_at->diffForHumans(),
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'views_count' => $viewsCount,
|
||||
'views_list' => $list
|
||||
]
|
||||
], 200);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
logger()->error('Worker Dashboard Views API Failure: ' . $e->getMessage());
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'An error occurred while fetching profile views statistics.',
|
||||
'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error'
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get consolidated worker dashboard details (Requirement: dashboard api).
|
||||
* GET /api/workers/dashboard
|
||||
*/
|
||||
public function getDashboard(Request $request)
|
||||
{
|
||||
/** @var Worker $worker */
|
||||
$worker = $request->attributes->get('worker');
|
||||
|
||||
try {
|
||||
// 1. Active status
|
||||
$activeStatus = $worker->status;
|
||||
|
||||
// 2. Count of sponsors who viewed the profile
|
||||
$viewsCount = ProfileView::where('worker_id', $worker->id)->count();
|
||||
|
||||
// 3. Currently working sponsor/employer
|
||||
$currentSponsor = null;
|
||||
$acceptedOffer = JobOffer::where('worker_id', $worker->id)
|
||||
->where('status', 'accepted')
|
||||
->with('employer.employerProfile')
|
||||
->first();
|
||||
|
||||
if ($acceptedOffer && $acceptedOffer->employer) {
|
||||
$emp = $acceptedOffer->employer;
|
||||
$empProfile = $emp->employerProfile;
|
||||
$currentSponsor = [
|
||||
'id' => $emp->id,
|
||||
'name' => $emp->name,
|
||||
'company_name' => $empProfile->company_name ?? 'Private Sponsor',
|
||||
'nationality' => $empProfile->nationality ?? 'UAE',
|
||||
'city' => $empProfile->city ?? 'Dubai',
|
||||
'email' => $emp->email,
|
||||
'phone' => $emp->phone,
|
||||
'hired_at' => $acceptedOffer->updated_at->toIso8601String(),
|
||||
'hired_at_formatted' => $acceptedOffer->updated_at->format('M d, Y'),
|
||||
];
|
||||
}
|
||||
|
||||
// 4. Latest charity events / announcements list
|
||||
$charityEvents = Announcement::with('employer.employerProfile')
|
||||
->latest()
|
||||
->limit(5)
|
||||
->get()
|
||||
->map(function ($event) {
|
||||
return [
|
||||
'id' => $event->id,
|
||||
'title' => $event->title,
|
||||
'body' => $event->body,
|
||||
'type' => $event->type,
|
||||
'employer_name' => $event->employer->name ?? 'System',
|
||||
'company_name' => $event->employer->employerProfile->company_name ?? 'Migrant Support',
|
||||
'created_at' => $event->created_at->toIso8601String(),
|
||||
'time_ago' => $event->created_at->diffForHumans(),
|
||||
];
|
||||
});
|
||||
|
||||
return response()->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'active_status' => $activeStatus,
|
||||
'count_sponsors_viewed' => $viewsCount,
|
||||
'currently_working_sponsor' => $currentSponsor,
|
||||
'latest_charity_events_list' => $charityEvents
|
||||
]
|
||||
], 200);
|
||||
|
||||
} catch (\Exception $e) {
|
||||
logger()->error('Worker Dashboard API Failure: ' . $e->getMessage());
|
||||
|
||||
return response()->json([
|
||||
'success' => false,
|
||||
'message' => 'An error occurred while loading the worker dashboard.',
|
||||
'error' => app()->environment('local') ? $e->getMessage() : 'Internal Server Error'
|
||||
], 500);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -9,7 +9,6 @@
|
||||
use App\Models\Conversation;
|
||||
use App\Models\Message;
|
||||
use App\Models\Worker;
|
||||
use App\Models\JobOffer;
|
||||
|
||||
class MessageController extends Controller
|
||||
{
|
||||
@ -37,62 +36,6 @@ private function resolveCurrentUser()
|
||||
return null;
|
||||
}
|
||||
|
||||
public static function processWorkerResponse($conv, $worker, $text)
|
||||
{
|
||||
$replyText = strtolower(trim($text));
|
||||
|
||||
if ($replyText === 'yes' || $replyText === 'no') {
|
||||
// Find the last message sent by the employer in this conversation (excluding the current worker message)
|
||||
$lastEmployerMessage = Message::where('conversation_id', $conv->id)
|
||||
->where('sender_type', 'employer')
|
||||
->latest()
|
||||
->first();
|
||||
|
||||
if ($lastEmployerMessage) {
|
||||
$questionText = strtolower($lastEmployerMessage->text);
|
||||
$isLookingJobQuestion = str_contains($questionText, 'looking job') ||
|
||||
str_contains($questionText, 'looking for a job') ||
|
||||
str_contains($questionText, 'looking for job') ||
|
||||
str_contains($questionText, 'are you looking');
|
||||
|
||||
if ($isLookingJobQuestion) {
|
||||
if ($replyText === 'yes') {
|
||||
// S6 Outcome: Update status as Hired
|
||||
$worker->update([
|
||||
'status' => 'Hired',
|
||||
'availability' => 'Hired'
|
||||
]);
|
||||
|
||||
// Accept existing direct offer or application
|
||||
$offer = JobOffer::where('employer_id', $conv->employer_id)
|
||||
->where('worker_id', $worker->id)
|
||||
->first();
|
||||
|
||||
if ($offer) {
|
||||
$offer->update(['status' => 'accepted']);
|
||||
} else {
|
||||
JobOffer::create([
|
||||
'employer_id' => $conv->employer_id,
|
||||
'worker_id' => $worker->id,
|
||||
'work_date' => now()->format('Y-m-d'),
|
||||
'location' => 'Dubai',
|
||||
'salary' => $worker->salary ?: 2000,
|
||||
'notes' => 'Hired via chat agreement.',
|
||||
'status' => 'accepted',
|
||||
]);
|
||||
}
|
||||
} else if ($replyText === 'no') {
|
||||
// S5 Outcome: Update status as Hidden (Auto-Hidden from search)
|
||||
$worker->update([
|
||||
'status' => 'hidden',
|
||||
'availability' => 'Not Available'
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$user = $this->resolveCurrentUser();
|
||||
@ -207,32 +150,6 @@ public function send(Request $request, $id)
|
||||
// Touch conversation updated_at for sorting
|
||||
$conv->touch();
|
||||
|
||||
// Check if employer sent predefined message "are you looking job?"
|
||||
$textLower = strtolower($request->text);
|
||||
if (
|
||||
str_contains($textLower, 'looking job') ||
|
||||
str_contains($textLower, 'looking for a job') ||
|
||||
str_contains($textLower, 'looking for job') ||
|
||||
str_contains($textLower, 'are you looking')
|
||||
) {
|
||||
// Automatically simulate worker response 'Yes' (triggers the S6 hired flow)
|
||||
$workerResponseText = 'Yes';
|
||||
|
||||
// Create the message in database
|
||||
Message::create([
|
||||
'conversation_id' => $conv->id,
|
||||
'sender_type' => 'worker',
|
||||
'sender_id' => $conv->worker_id,
|
||||
'text' => $workerResponseText,
|
||||
]);
|
||||
|
||||
// Process the worker response to update status to Hired!
|
||||
$worker = Worker::find($conv->worker_id);
|
||||
if ($worker) {
|
||||
self::processWorkerResponse($conv, $worker, $workerResponseText);
|
||||
}
|
||||
}
|
||||
|
||||
return back();
|
||||
}
|
||||
|
||||
|
||||
@ -1,100 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Employer;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Inertia\Inertia;
|
||||
use App\Models\User;
|
||||
use App\Models\Payment;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
class PaymentController extends Controller
|
||||
{
|
||||
private function resolveCurrentUser()
|
||||
{
|
||||
$sess = session('user');
|
||||
$sessId = is_array($sess) ? ($sess['id'] ?? null) : ($sess->id ?? null);
|
||||
|
||||
if (!$sessId) {
|
||||
$user = User::where('role', 'employer')->first();
|
||||
if ($user) {
|
||||
session(['user' => (object)[
|
||||
'id' => $user->id,
|
||||
'name' => $user->name,
|
||||
'email' => $user->email,
|
||||
'role' => 'employer',
|
||||
'subscription_status' => $user->subscription_status ?? 'active',
|
||||
]]);
|
||||
return $user;
|
||||
}
|
||||
} else {
|
||||
return User::find($sessId);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
public function index(Request $request)
|
||||
{
|
||||
$user = $this->resolveCurrentUser();
|
||||
$employerId = $user ? $user->id : 2;
|
||||
|
||||
// Auto-seed a couple of payments if none exist for a richer UX
|
||||
$paymentsCount = Payment::where('user_id', $employerId)->count();
|
||||
if ($paymentsCount === 0) {
|
||||
$subscription = DB::table('subscriptions')->where('user_id', $employerId)->first();
|
||||
$planName = $subscription ? (ucfirst($subscription->plan_id) . ' Pass Subscription') : 'Premium Sponsor Pass Subscription';
|
||||
$planAmount = $subscription ? $subscription->amount_aed : 199.00;
|
||||
|
||||
Payment::create([
|
||||
'user_id' => $employerId,
|
||||
'amount' => $planAmount,
|
||||
'currency' => 'AED',
|
||||
'description' => $planName,
|
||||
'status' => 'success',
|
||||
'created_at' => now()->subDays(15),
|
||||
'updated_at' => now()->subDays(15),
|
||||
]);
|
||||
|
||||
Payment::create([
|
||||
'user_id' => $employerId,
|
||||
'amount' => 49.00,
|
||||
'currency' => 'AED',
|
||||
'description' => 'OCR Document Vetting Fee',
|
||||
'status' => 'success',
|
||||
'created_at' => now()->subDays(5),
|
||||
'updated_at' => now()->subDays(5),
|
||||
]);
|
||||
}
|
||||
|
||||
$payments = Payment::where('user_id', $employerId)
|
||||
->latest()
|
||||
->get()
|
||||
->map(function ($p) {
|
||||
return [
|
||||
'id' => $p->id,
|
||||
'amount' => (float)$p->amount,
|
||||
'currency' => $p->currency,
|
||||
'description' => $p->description,
|
||||
'status' => $p->status,
|
||||
'date' => $p->created_at->format('M d, Y'),
|
||||
'time' => $p->created_at->format('h:i A'),
|
||||
'invoice_no' => 'INV-' . str_pad($p->id, 6, '0', STR_PAD_LEFT),
|
||||
];
|
||||
})->toArray();
|
||||
|
||||
// Retrieve subscription details for billing and renewal analytics
|
||||
$sub = DB::table('subscriptions')->where('user_id', $employerId)->where('status', 'active')->latest('id')->first();
|
||||
$expiresAt = $sub && $sub->expires_at ? date('M d, Y', strtotime($sub->expires_at)) : 'Dec 31, 2026';
|
||||
$currentPlan = $sub ? (ucfirst($sub->plan_id) . ' Sponsor Pass') : 'Premium Sponsor Pass';
|
||||
$subStatus = $user && $user->subscription_status ? ucfirst($user->subscription_status) : 'Active';
|
||||
|
||||
return Inertia::render('Employer/PaymentHistory', [
|
||||
'payments' => $payments,
|
||||
'currentPlan' => $currentPlan,
|
||||
'expiresAt' => $expiresAt,
|
||||
'subscriptionStatus' => $subStatus,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@ -10,8 +10,6 @@
|
||||
use App\Models\WorkerCategory;
|
||||
use App\Models\Shortlist;
|
||||
use App\Models\JobOffer;
|
||||
use App\Models\Review;
|
||||
use App\Models\ProfileView;
|
||||
|
||||
class WorkerController extends Controller
|
||||
{
|
||||
@ -137,15 +135,6 @@ public function show($id)
|
||||
{
|
||||
$w = Worker::with(['category', 'skills', 'documents'])->findOrFail($id);
|
||||
|
||||
// Record employer profile view (Requirement 3)
|
||||
$user = $this->resolveCurrentUser();
|
||||
if ($user) {
|
||||
ProfileView::updateOrCreate(
|
||||
['employer_id' => $user->id, 'worker_id' => $w->id],
|
||||
['updated_at' => now()]
|
||||
);
|
||||
}
|
||||
|
||||
$langs = ($w->id % 2 === 0) ? ['English', 'Arabic'] : ( ($w->id % 3 === 0) ? ['English', 'Tagalog'] : ['English', 'Hindi', 'Arabic'] );
|
||||
|
||||
$jobTypes = ['full-time', 'part-time', 'live-in', 'live-out'];
|
||||
@ -169,41 +158,25 @@ public function show($id)
|
||||
];
|
||||
$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();
|
||||
$rating = 4.0 + (($w->id * 3) % 10) / 10.0;
|
||||
$reviewsCount = ($w->id * 4) % 20 + 2;
|
||||
|
||||
$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.',
|
||||
]
|
||||
];
|
||||
}
|
||||
$reviews = [
|
||||
[
|
||||
'id' => 1,
|
||||
'employer_name' => 'Fatima Al Mansoori',
|
||||
'rating' => 5,
|
||||
'date' => 'May 10, 2026',
|
||||
'comment' => 'Extremely reliable and respectful. Professional work and great communication.',
|
||||
],
|
||||
[
|
||||
'id' => 2,
|
||||
'employer_name' => 'Michael Harrison',
|
||||
'rating' => 4,
|
||||
'date' => 'Mar 24, 2026',
|
||||
'comment' => 'Very punctual, did exactly what was expected. Highly recommend.',
|
||||
]
|
||||
];
|
||||
|
||||
$simDb = Worker::with('category')
|
||||
->where('id', '!=', $w->id)
|
||||
|
||||
@ -14,15 +14,11 @@ class Message extends Model
|
||||
'sender_type',
|
||||
'sender_id',
|
||||
'text',
|
||||
'attachment_path',
|
||||
'attachment_type',
|
||||
'is_read',
|
||||
'read_at',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'is_read' => 'boolean',
|
||||
'read_at' => 'datetime',
|
||||
];
|
||||
|
||||
public function conversation()
|
||||
|
||||
@ -1,28 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Payment extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'amount',
|
||||
'currency',
|
||||
'description',
|
||||
'status',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'amount' => 'decimal:2',
|
||||
];
|
||||
|
||||
public function user()
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
@ -1,26 +0,0 @@
|
||||
<?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');
|
||||
}
|
||||
}
|
||||
@ -1,28 +0,0 @@
|
||||
<?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');
|
||||
}
|
||||
}
|
||||
@ -65,9 +65,4 @@ public function announcements()
|
||||
{
|
||||
return $this->hasMany(Announcement::class, 'employer_id');
|
||||
}
|
||||
|
||||
public function payments()
|
||||
{
|
||||
return $this->hasMany(Payment::class, 'user_id');
|
||||
}
|
||||
}
|
||||
|
||||
@ -60,14 +60,4 @@ public function offers()
|
||||
{
|
||||
return $this->hasMany(JobOffer::class, 'worker_id');
|
||||
}
|
||||
|
||||
public function reviews()
|
||||
{
|
||||
return $this->hasMany(Review::class);
|
||||
}
|
||||
|
||||
public function profileViews()
|
||||
{
|
||||
return $this->hasMany(ProfileView::class);
|
||||
}
|
||||
}
|
||||
|
||||
@ -24,10 +24,5 @@
|
||||
]);
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
$exceptions->shouldRenderJsonWhen(function (\Illuminate\Http\Request $request, \Throwable $e) {
|
||||
if ($request->is('api/*')) {
|
||||
return true;
|
||||
}
|
||||
return $request->expectsJson();
|
||||
});
|
||||
//
|
||||
})->create();
|
||||
|
||||
@ -65,7 +65,7 @@
|
||||
|
|
||||
*/
|
||||
|
||||
'timezone' => env('APP_TIMEZONE', 'Asia/Dubai'),
|
||||
'timezone' => 'UTC',
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
|
||||
@ -1,193 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
// 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');
|
||||
}
|
||||
};
|
||||
@ -1,37 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -1,125 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('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');
|
||||
}
|
||||
};
|
||||
@ -1,34 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::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');
|
||||
}
|
||||
};
|
||||
@ -1,32 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::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');
|
||||
}
|
||||
};
|
||||
@ -1,29 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('messages', function (Blueprint $table) {
|
||||
$table->string('attachment_path')->nullable()->after('text');
|
||||
$table->string('attachment_type')->nullable()->after('attachment_path');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('messages', function (Blueprint $table) {
|
||||
$table->dropColumn(['attachment_path', 'attachment_type']);
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -1,28 +0,0 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('messages', function (Blueprint $table) {
|
||||
$table->text('text')->nullable()->change();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('messages', function (Blueprint $table) {
|
||||
$table->text('text')->nullable(false)->change();
|
||||
});
|
||||
}
|
||||
};
|
||||
4503
public/swagger.json
4503
public/swagger.json
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
Before Width: | Height: | Size: 112 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 109 KiB |
@ -16,9 +16,7 @@ import {
|
||||
UserCheck,
|
||||
Megaphone,
|
||||
Briefcase,
|
||||
Heart,
|
||||
FileText,
|
||||
ChevronDown
|
||||
Heart
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
DropdownMenu,
|
||||
@ -69,7 +67,6 @@ export default function EmployerLayout({ children, title, fullPage = false }) {
|
||||
{ name: 'Messages', translationKey: 'messages', href: '/employer/messages', icon: MessageSquare, badge: unread_messages_count },
|
||||
{ name: 'Charity Events', translationKey: 'charity_events', href: '/employer/announcements', icon: Heart },
|
||||
{ name: 'Subscription', translationKey: 'subscription', href: '/employer/subscription', icon: CreditCard },
|
||||
{ name: 'Payment History', translationKey: 'payment_history', href: '/employer/payments', icon: FileText },
|
||||
{ name: 'My Profile', translationKey: 'my_profile', href: '/employer/profile', icon: User },
|
||||
];
|
||||
|
||||
@ -211,7 +208,9 @@ export default function EmployerLayout({ children, title, fullPage = false }) {
|
||||
<span>{languages.find(l => l.code === locale)?.flag}</span>
|
||||
<span className="uppercase text-[10px] font-black tracking-wider">{locale}</span>
|
||||
</span>
|
||||
<ChevronDown className="w-3.5 h-3.5 text-slate-500 transition-transform duration-200" />
|
||||
<svg className="w-2.5 h-2.5 text-slate-500 transition-transform duration-200 group-data-[state=open]:rotate-180" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth="2.5">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
|
||||
</svg>
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-48 mt-2 p-1 rounded-xl border border-slate-200 bg-white/95 backdrop-blur-md shadow-xl z-50">
|
||||
|
||||
@ -2,288 +2,321 @@ import React, { useState } from 'react';
|
||||
import { Head } from '@inertiajs/react';
|
||||
import AdminLayout from '@/Layouts/AdminLayout';
|
||||
import {
|
||||
FileText,
|
||||
BarChart3,
|
||||
TrendingUp,
|
||||
Users,
|
||||
ShieldAlert,
|
||||
CreditCard,
|
||||
Search,
|
||||
RefreshCw,
|
||||
Download,
|
||||
Building2,
|
||||
DollarSign
|
||||
Percent,
|
||||
Award,
|
||||
Sparkles,
|
||||
Calendar,
|
||||
Download
|
||||
} from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
export default function AnalyticsHub({
|
||||
worker_stats = { total: 0, verified: 0, active: 0, inactive: 0 },
|
||||
sponsor_stats = { total: 0, verified: 0, active_subscribers: 0, unpaid: 0 },
|
||||
dispute_stats = { total: 0, open: 0, under_review: 0, resolved: 0 },
|
||||
safety_stats = { total: 0, pending: 0, resolved: 0 },
|
||||
payments = [],
|
||||
total_revenue = 0
|
||||
}) {
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
export default function AnalyticsHub({ user_growth, revenue_breakdown, retention_rates, hiring_funnel }) {
|
||||
const [selectedTab, setSelectedTab] = useState('Growth');
|
||||
|
||||
// Filter payments dynamically
|
||||
const filteredPayments = payments.filter(payment => {
|
||||
const matchesSearch =
|
||||
payment.id.toString().toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
payment.user_name.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
payment.user_email.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
payment.description.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
// Helper coordinates calculation for Cohort growth Area Chart (450w, 200h)
|
||||
const getCoordinatesForArea = (key, maxVal) => {
|
||||
const points = [];
|
||||
const paddingLeft = 40;
|
||||
const paddingRight = 15;
|
||||
const paddingTop = 20;
|
||||
const paddingBottom = 25;
|
||||
|
||||
const matchesStatus = statusFilter === 'all' || payment.status.toLowerCase() === statusFilter.toLowerCase();
|
||||
const graphHeight = 200 - paddingTop - paddingBottom;
|
||||
const graphWidth = 450 - paddingLeft - paddingRight;
|
||||
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
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}`);
|
||||
});
|
||||
|
||||
const handleExportCSV = () => {
|
||||
const headers = ['Payment ID,Customer,Email,Details,Amount,Status,Date\n'];
|
||||
const rows = filteredPayments.map(p =>
|
||||
`"${p.id}","${p.user_name}","${p.user_email}","${p.description}",${p.amount},"${p.status}","${p.created_at}"`
|
||||
);
|
||||
const blob = new Blob([headers.concat(rows.join('\n'))], { type: 'text/csv;charset=utf-8;' });
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.setAttribute('download', `financial_report_${new Date().toISOString().split('T')[0]}.csv`);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
// Add bottom-right and bottom-left to close the area shape
|
||||
const startX = paddingLeft;
|
||||
const endX = paddingLeft + graphWidth;
|
||||
const bottomY = paddingTop + graphHeight;
|
||||
|
||||
return `${startX},${bottomY} ${points.join(' ')} ${endX},${bottomY}`;
|
||||
};
|
||||
|
||||
const getLineCoordinates = (key, maxVal) => {
|
||||
const points = [];
|
||||
const paddingLeft = 40;
|
||||
const paddingRight = 15;
|
||||
const paddingTop = 20;
|
||||
const paddingBottom = 25;
|
||||
|
||||
const graphHeight = 200 - paddingTop - paddingBottom;
|
||||
const graphWidth = 450 - paddingLeft - paddingRight;
|
||||
|
||||
user_growth.forEach((d, idx) => {
|
||||
const val = d[key];
|
||||
const x = paddingLeft + (idx / (user_growth.length - 1)) * graphWidth;
|
||||
const y = paddingTop + graphHeight - (val / maxVal) * graphHeight;
|
||||
points.push(`${x},${y}`);
|
||||
});
|
||||
return points.join(' ');
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminLayout title="Reports">
|
||||
<Head title="Reports" />
|
||||
<AdminLayout title="Reports & Analytics Hub">
|
||||
<Head title="System Analytics" />
|
||||
|
||||
<div className="font-sans max-w-7xl mx-auto space-y-8 pb-12">
|
||||
<div className="font-sans max-w-7xl mx-auto space-y-8">
|
||||
|
||||
{/* Simplified Header */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 border-b border-slate-200 pb-5">
|
||||
{/* Title and date picker */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-black text-slate-800 uppercase tracking-tight">Platform Reports</h1>
|
||||
<p className="text-xs text-slate-500 mt-0.5 font-medium">Summary of workers, sponsors, disputes, and payments.</p>
|
||||
<h1 className="text-xl font-bold text-gray-900 tracking-tight">Interactive Platform Reports & Analytics Hub</h1>
|
||||
<p className="text-xs text-gray-500 mt-0.5">Explore subscription cohort retention, job conversions ratios, availability trends, and monthly billing splits.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="inline-flex items-center px-3.5 py-2 bg-white border border-slate-200 text-slate-700 rounded-xl text-xs font-black uppercase tracking-wider hover:bg-slate-50 transition-colors shadow-sm gap-1.5"
|
||||
>
|
||||
<RefreshCw className="w-3.5 h-3.5" />
|
||||
<span>Refresh</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExportCSV}
|
||||
className="inline-flex items-center px-4 py-2 bg-[#0F6E56] hover:bg-[#085041] text-white rounded-xl text-xs font-black uppercase tracking-wider transition-all shadow-md gap-1.5"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
<span>Export CSV</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex bg-slate-200/60 p-1 rounded-xl w-fit border border-slate-200">
|
||||
{['Growth', 'Revenue', 'Funnel'].map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
type="button"
|
||||
onClick={() => setSelectedTab(tab)}
|
||||
className={`px-4 py-1.5 rounded-lg text-[10px] font-black uppercase tracking-wider transition-all ${
|
||||
selectedTab === tab
|
||||
? 'bg-white text-[#0F6E56] shadow-sm'
|
||||
: 'text-slate-600 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
{tab} Report
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<button className="inline-flex items-center px-4 py-2.5 bg-slate-950 text-white rounded-xl text-xs font-bold hover:bg-slate-900 transition-colors shadow-sm space-x-2 uppercase tracking-wider">
|
||||
<Download className="w-4 h-4" />
|
||||
<span>Export PDF</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Simplified Summary Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{/* Analytical Charts grid depending on tabs */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
|
||||
{/* Workers Card */}
|
||||
<div className="bg-white border border-slate-200 rounded-2xl p-5 shadow-sm space-y-4 hover:border-[#0F6E56]/30 transition-all">
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="p-2.5 bg-teal-50 rounded-xl text-[#0F6E56]">
|
||||
<Users className="w-5 h-5" />
|
||||
</div>
|
||||
<span className="text-[10px] bg-slate-105 text-[#0F6E56] font-extrabold uppercase px-2 py-0.5 rounded tracking-wide">
|
||||
Workers
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[10px] text-slate-400 font-bold uppercase tracking-wider block">Total Workers</span>
|
||||
<span className="text-3xl font-black text-slate-800 block mt-1 tracking-tight">{worker_stats.total}</span>
|
||||
</div>
|
||||
<div className="pt-2 border-t border-slate-100 grid grid-cols-2 gap-2 text-[10px] font-bold text-slate-500">
|
||||
{/* Growth cohort Report */}
|
||||
{selectedTab === 'Growth' && (
|
||||
<div className="bg-white border border-slate-200 rounded-3xl p-6 shadow-sm space-y-6">
|
||||
<div>
|
||||
<span className="text-slate-400 uppercase tracking-widest text-[8px] block">Verified</span>
|
||||
<span className="text-slate-700 font-extrabold text-xs">{worker_stats.verified}</span>
|
||||
<h3 className="text-sm font-black text-slate-800 uppercase tracking-widest flex items-center gap-1.5">
|
||||
<TrendingUp className="w-4.5 h-4.5 text-[#0F6E56]" />
|
||||
<span>User Cohort growth curves</span>
|
||||
</h3>
|
||||
<p className="text-[10px] text-slate-400 mt-0.5 uppercase tracking-wider font-bold">Employer and Worker registry trends (6 Months)</p>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-400 uppercase tracking-widest text-[8px] block">Active</span>
|
||||
<span className="text-slate-700 font-extrabold text-xs">{worker_stats.active}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sponsors Card */}
|
||||
<div className="bg-white border border-slate-200 rounded-2xl p-5 shadow-sm space-y-4 hover:border-[#0F6E56]/30 transition-all">
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="p-2.5 bg-blue-50 rounded-xl text-blue-600">
|
||||
<Building2 className="w-5 h-5" />
|
||||
</div>
|
||||
<span className="text-[10px] bg-blue-50 text-blue-700 font-extrabold uppercase px-2 py-0.5 rounded tracking-wide">
|
||||
Sponsors
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[10px] text-slate-400 font-bold uppercase tracking-wider block">Total Sponsors</span>
|
||||
<span className="text-3xl font-black text-slate-800 block mt-1 tracking-tight">{sponsor_stats.total}</span>
|
||||
</div>
|
||||
<div className="pt-2 border-t border-slate-100 grid grid-cols-2 gap-2 text-[10px] font-bold text-slate-500">
|
||||
<div>
|
||||
<span className="text-slate-400 uppercase tracking-widest text-[8px] block">Active Plan</span>
|
||||
<span className="text-slate-700 font-extrabold text-xs">{sponsor_stats.active_subscribers}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-400 uppercase tracking-widest text-[8px] block">Unpaid</span>
|
||||
<span className="text-slate-700 font-extrabold text-xs">{sponsor_stats.unpaid}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Disputes Card */}
|
||||
<div className="bg-white border border-slate-200 rounded-2xl p-5 shadow-sm space-y-4 hover:border-[#0F6E56]/30 transition-all">
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="p-2.5 bg-orange-50 rounded-xl text-orange-600">
|
||||
<ShieldAlert className="w-5 h-5" />
|
||||
</div>
|
||||
<span className="text-[10px] bg-orange-50 text-orange-700 font-extrabold uppercase px-2 py-0.5 rounded tracking-wide">
|
||||
Disputes
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[10px] text-slate-400 font-bold uppercase tracking-wider block">Total Disputes</span>
|
||||
<span className="text-3xl font-black text-slate-800 block mt-1 tracking-tight">{dispute_stats.open + dispute_stats.under_review}</span>
|
||||
</div>
|
||||
<div className="pt-2 border-t border-slate-100 grid grid-cols-2 gap-2 text-[10px] font-bold text-slate-500">
|
||||
<div>
|
||||
<span className="text-slate-400 uppercase tracking-widest text-[8px] block">Resolved</span>
|
||||
<span className="text-slate-700 font-extrabold text-xs">{dispute_stats.resolved}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-400 uppercase tracking-widest text-[8px] block">Pending Flags</span>
|
||||
<span className="text-slate-700 font-extrabold text-xs">{safety_stats.pending}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Revenue Card */}
|
||||
<div className="bg-white border border-slate-200 rounded-2xl p-5 shadow-sm space-y-4 hover:border-[#0F6E56]/30 transition-all">
|
||||
<div className="flex justify-between items-start">
|
||||
<div className="p-2.5 bg-emerald-50 rounded-xl text-emerald-600">
|
||||
<DollarSign className="w-5 h-5" />
|
||||
</div>
|
||||
<span className="text-[10px] bg-emerald-50 text-emerald-700 font-extrabold uppercase px-2 py-0.5 rounded tracking-wide">
|
||||
Revenue
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-[10px] text-slate-400 font-bold uppercase tracking-wider block">Total Revenue</span>
|
||||
<span className="text-3xl font-black text-slate-800 block mt-1 tracking-tight">
|
||||
AED {total_revenue.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="pt-2 border-t border-slate-100 grid grid-cols-2 gap-2 text-[10px] font-bold text-slate-500">
|
||||
<div>
|
||||
<span className="text-slate-400 uppercase tracking-widest text-[8px] block">Total Paid</span>
|
||||
<span className="text-slate-700 font-extrabold text-xs">{payments.length}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-slate-400 uppercase tracking-widest text-[8px] block">Average Amount</span>
|
||||
<span className="text-slate-700 font-extrabold text-xs">
|
||||
AED {payments.length > 0 ? (total_revenue / payments.length).toFixed(0) : '0'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Simplified Payment Ledger */}
|
||||
<div className="bg-white border border-slate-200 rounded-3xl p-6 shadow-sm space-y-6">
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 border-b border-slate-100 pb-5">
|
||||
<div>
|
||||
<h3 className="text-sm font-black text-slate-800 uppercase tracking-widest flex items-center gap-2">
|
||||
<CreditCard className="w-4.5 h-4.5 text-[#0F6E56]" />
|
||||
<span>Payment List</span>
|
||||
</h3>
|
||||
<p className="text-[10px] text-slate-400 font-semibold mt-0.5">Search and check payments.</p>
|
||||
</div>
|
||||
|
||||
{/* Search & Status Filters */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="relative">
|
||||
<Search className="w-4 h-4 text-slate-400 absolute left-3.5 top-1/2 -translate-y-1/2" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search payments..."
|
||||
className="bg-slate-50 border border-slate-200 rounded-xl pl-9 pr-4 py-2 text-xs font-semibold focus:bg-white outline-none focus:ring-2 focus:ring-[#0F6E56]/10 w-64 shadow-sm"
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
<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>
|
||||
<h3 className="text-sm font-black text-slate-800 uppercase tracking-widest flex items-center gap-1.5">
|
||||
<CreditCard className="w-4.5 h-4.5 text-[#0F6E56]" />
|
||||
<span>Stacked Subscription revenue splits</span>
|
||||
</h3>
|
||||
<p className="text-[10px] text-slate-400 mt-0.5 uppercase tracking-wider font-bold">Monthly billing splits (AED)</p>
|
||||
</div>
|
||||
|
||||
<select
|
||||
className="bg-slate-50 border border-slate-200 rounded-xl px-4 py-2 text-xs font-semibold outline-none cursor-pointer focus:bg-white focus:ring-2 focus:ring-[#0F6E56]/10 shadow-sm"
|
||||
value={statusFilter}
|
||||
onChange={e => setStatusFilter(e.target.value)}
|
||||
>
|
||||
<option value="all">All Payments</option>
|
||||
<option value="success">Success</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="failed">Failed</option>
|
||||
</select>
|
||||
<div className="relative">
|
||||
<svg className="w-full h-56" viewBox="0 0 450 200">
|
||||
<line x1="40" y1="180" x2="435" y2="180" stroke="#cbd5e1" strokeWidth="1.5" />
|
||||
|
||||
{/* Stacked bars for each month */}
|
||||
{revenue_breakdown.map((d, i) => {
|
||||
const x = 50 + i * 62;
|
||||
const total = d.basic + d.premium + d.vip;
|
||||
const maxVal = 80000;
|
||||
const scale = 150; // Graph height scale factor
|
||||
|
||||
const basicH = (d.basic / maxVal) * scale;
|
||||
const premiumH = (d.premium / maxVal) * scale;
|
||||
const vipH = (d.vip / maxVal) * scale;
|
||||
|
||||
const basicY = 180 - basicH;
|
||||
const premiumY = basicY - premiumH;
|
||||
const vipY = premiumY - vipH;
|
||||
|
||||
return (
|
||||
<g key={i} className="cursor-pointer group">
|
||||
{/* Basic */}
|
||||
<rect x={x} y={basicY} width="22" height={basicH} fill="#94a3b8" rx="2" />
|
||||
{/* Premium */}
|
||||
<rect x={x} y={premiumY} width="22" height={premiumH} fill="#3b82f6" />
|
||||
{/* VIP */}
|
||||
<rect x={x} y={vipY} width="22" height={vipH} fill="#10b981" rx="2" />
|
||||
|
||||
<text x={x + 11} y="195" textAnchor="middle" className="text-[9px] font-bold fill-slate-400 font-mono">
|
||||
{d.month.split(' ')[0]}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
<text x="30" y="30" textAnchor="end" className="text-[9px] font-black fill-slate-300">80K</text>
|
||||
<text x="30" y="105" textAnchor="end" className="text-[9px] font-black fill-slate-300">40K</text>
|
||||
<text x="30" y="180" textAnchor="end" className="text-[9px] font-black fill-slate-300">0</text>
|
||||
</svg>
|
||||
|
||||
<div className="flex items-center justify-center space-x-6 mt-4">
|
||||
<div className="flex items-center space-x-2 text-xs font-bold text-slate-600">
|
||||
<div className="w-3 h-3 bg-slate-400 rounded-sm" />
|
||||
<span>Basic</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 text-xs font-bold text-slate-600">
|
||||
<div className="w-3 h-3 bg-blue-500 rounded-sm" />
|
||||
<span>Premium</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 text-xs font-bold text-slate-600">
|
||||
<div className="w-3 h-3 bg-emerald-500 rounded-sm" />
|
||||
<span>VIP</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Funnel Conversions */}
|
||||
{selectedTab === 'Funnel' && (
|
||||
<div className="bg-white border border-slate-200 rounded-3xl p-6 shadow-sm space-y-6">
|
||||
<div>
|
||||
<h3 className="text-sm font-black text-slate-800 uppercase tracking-widest flex items-center gap-1.5">
|
||||
<Percent className="w-4.5 h-4.5 text-[#0F6E56]" />
|
||||
<span>Hiring Placement Funnel ratios</span>
|
||||
</h3>
|
||||
<p className="text-[10px] text-slate-400 mt-0.5 uppercase tracking-wider font-bold">Hiring Conversion stage details (This month)</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{hiring_funnel.map((item, idx) => {
|
||||
const maxVal = hiring_funnel[0].count;
|
||||
const pct = ((item.count / maxVal) * 100).toFixed(1);
|
||||
return (
|
||||
<div key={idx} className="space-y-2 text-xs font-bold text-slate-700">
|
||||
<div className="flex items-center justify-between">
|
||||
<span>{item.stage}</span>
|
||||
<span>{item.count.toLocaleString()} ({pct}%)</span>
|
||||
</div>
|
||||
<div className="w-full bg-slate-100 rounded-full h-3.5 overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full bg-gradient-to-r from-teal-600 to-teal-500 transition-all duration-500"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Right side cohort Retention Analysis: Month-over-Month curves */}
|
||||
<div className="bg-white border border-slate-200 rounded-3xl p-6 shadow-sm flex flex-col justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-black text-slate-800 uppercase tracking-widest flex items-center gap-1.5">
|
||||
<Award className="w-4.5 h-4.5 text-[#0F6E56]" />
|
||||
<span>Subscription Cohort Retention rates</span>
|
||||
</h3>
|
||||
<p className="text-[10px] text-slate-400 mt-0.5 uppercase tracking-wider font-bold">Month 1 to Month 6 user retention metrics</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4 my-6">
|
||||
{retention_rates.map((cohort, i) => (
|
||||
<div key={i} className="flex items-center justify-between text-xs font-bold text-slate-700">
|
||||
<span className="w-20">{cohort.cohort}</span>
|
||||
<div className="flex-1 mx-4 bg-slate-100 rounded-full h-3 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-blue-600 to-blue-500 rounded-full"
|
||||
style={{ width: `${cohort.rate}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-blue-600 w-10 text-right">{cohort.rate}%</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="bg-emerald-50/50 p-4 border border-emerald-100 rounded-2xl text-[11px] font-bold text-teal-800 leading-relaxed shadow-sm">
|
||||
<Sparkles className="w-4 h-4 text-emerald-600 mb-1" />
|
||||
<span>System Insights: Employer VIP and Premium tier subscribers yield a 79% retention rate beyond Month 3, validating platform organic vetting value.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Payments Table */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-150 text-[10px] text-slate-400 font-black uppercase tracking-widest">
|
||||
<th className="px-5 py-3">Payment ID</th>
|
||||
<th className="px-5 py-3">Customer</th>
|
||||
<th className="px-5 py-3">Details</th>
|
||||
<th className="px-5 py-3">Amount</th>
|
||||
<th className="px-5 py-3">Status</th>
|
||||
<th className="px-5 py-3">Date</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100 text-xs font-bold text-slate-700">
|
||||
{filteredPayments.map(p => (
|
||||
<tr key={p.id} className="hover:bg-slate-50/50 transition-colors">
|
||||
<td className="px-5 py-4 font-mono text-[#0F6E56]">TXN-{p.id}</td>
|
||||
<td className="px-5 py-4">
|
||||
<div>
|
||||
<span className="font-extrabold text-slate-800 block">{p.user_name}</span>
|
||||
<span className="text-[10px] text-slate-400 font-semibold block">{p.user_email}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-4 font-semibold text-slate-650">{p.description}</td>
|
||||
<td className="px-5 py-4 font-extrabold text-slate-800">
|
||||
{p.amount.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })} {p.currency}
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<span className={`px-2 py-0.5 rounded-full text-[9px] font-black uppercase tracking-wide ${
|
||||
p.status === 'success' ? 'bg-emerald-50 text-emerald-700 border border-emerald-100' :
|
||||
p.status === 'pending' ? 'bg-amber-50 text-amber-700 border border-amber-100' :
|
||||
'bg-red-50 text-red-700 border border-red-100'
|
||||
}`}>
|
||||
{p.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-4 text-slate-400 font-semibold">{p.created_at}</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
{filteredPayments.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan="6" className="p-12 text-center text-slate-400 font-bold uppercase">
|
||||
No payments found.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</AdminLayout>
|
||||
);
|
||||
|
||||
@ -3,9 +3,17 @@ import { Head, router } from '@inertiajs/react';
|
||||
import AdminLayout from '@/Layouts/AdminLayout';
|
||||
import {
|
||||
Search,
|
||||
Filter,
|
||||
History,
|
||||
ShieldCheck,
|
||||
User,
|
||||
CreditCard,
|
||||
Scale,
|
||||
AlertTriangle,
|
||||
Eye,
|
||||
Globe,
|
||||
Terminal
|
||||
Terminal,
|
||||
ArrowRight
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Table,
|
||||
@ -17,7 +25,7 @@ import {
|
||||
} from '@/components/ui/table';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
export default function AuditLogsHub({ logs = [], search = '', category = 'all' }) {
|
||||
export default function AuditLogsHub({ logs, search, category }) {
|
||||
const [searchTerm, setSearchTerm] = useState(search || '');
|
||||
const [selectedCategory, setSelectedCategory] = useState(category || 'all');
|
||||
|
||||
@ -40,16 +48,16 @@ export default function AuditLogsHub({ logs = [], search = '', category = 'all'
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminLayout title="Logs">
|
||||
<Head title="Logs" />
|
||||
<AdminLayout title="System Compliance Audit Logs">
|
||||
<Head title="System Audit Logs" />
|
||||
|
||||
<div className="font-sans max-w-7xl mx-auto space-y-6 pb-12">
|
||||
<div className="font-sans max-w-7xl mx-auto space-y-6">
|
||||
|
||||
{/* Simplified Header */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 border-b border-slate-200 pb-5">
|
||||
{/* Header text */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-black text-slate-800 uppercase tracking-tight">System Logs</h1>
|
||||
<p className="text-xs text-slate-500 mt-0.5 font-medium">Detailed history of system events.</p>
|
||||
<h1 className="text-xl font-bold text-gray-900 tracking-tight">Centralized Compliance & Security Audit Logs</h1>
|
||||
<p className="text-xs text-gray-500 mt-0.5">Track every critical administrative event, auto-OCR verification, stripe checkout charge, and user moderation updates.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -57,17 +65,17 @@ export default function AuditLogsHub({ logs = [], search = '', category = 'all'
|
||||
<div className="bg-white p-4 rounded-2xl border border-slate-200 shadow-sm flex flex-col md:flex-row gap-4 items-center justify-between">
|
||||
<form onSubmit={handleSearchSubmit} className="flex flex-1 items-center space-x-3 w-full md:w-auto">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search logs..."
|
||||
className="w-full pl-10 pr-4 py-2.5 bg-slate-50 border border-slate-100 rounded-xl text-xs font-semibold focus:bg-white focus:ring-2 focus:ring-[#0F6E56]/10 outline-none transition-all"
|
||||
placeholder="Search logs by IP, actor, message or ticket ID..."
|
||||
className="w-full pl-10 pr-4 py-2.5 bg-slate-50 border border-slate-100 rounded-xl text-sm font-medium focus:bg-white focus:ring-4 focus:ring-teal-500/10 outline-none transition-all"
|
||||
value={searchTerm}
|
||||
onChange={e => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="px-4 py-2.5 bg-[#0F6E56] hover:bg-[#085041] text-white rounded-xl text-xs font-black uppercase tracking-wider transition-colors">
|
||||
Search
|
||||
<button type="submit" className="px-4 py-2.5 bg-slate-900 hover:bg-slate-800 text-white rounded-xl text-xs font-black uppercase tracking-wider">
|
||||
Search Logs
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@ -76,10 +84,10 @@ export default function AuditLogsHub({ logs = [], search = '', category = 'all'
|
||||
{[
|
||||
{ label: 'All Logs', val: 'all' },
|
||||
{ label: 'Admin Actions', val: 'admin_action' },
|
||||
{ label: 'Verifications', val: 'verification' },
|
||||
{ label: 'OCR Checks', val: 'verification' },
|
||||
{ label: 'Payments', val: 'payment' },
|
||||
{ label: 'Disputes', val: 'dispute' },
|
||||
{ label: 'Logins', val: 'user_activity' }
|
||||
{ label: 'User Logins', val: 'user_activity' }
|
||||
].map((cat) => (
|
||||
<button
|
||||
key={cat.val}
|
||||
@ -100,18 +108,18 @@ export default function AuditLogsHub({ logs = [], search = '', category = 'all'
|
||||
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-slate-50/50 hover:bg-slate-50/50 border-b border-slate-150">
|
||||
<TableHead className="font-bold text-slate-550 text-[10px] uppercase tracking-widest h-14 pl-8">Log ID & Category</TableHead>
|
||||
<TableHead className="font-bold text-slate-550 text-[10px] uppercase tracking-widest h-14">User</TableHead>
|
||||
<TableHead className="font-bold text-slate-550 text-[10px] uppercase tracking-widest h-14">Action</TableHead>
|
||||
<TableHead className="font-bold text-slate-550 text-[10px] uppercase tracking-widest h-14">IP Address</TableHead>
|
||||
<TableHead className="font-bold text-slate-550 text-[10px] uppercase tracking-widest h-14 text-right pr-8">Time</TableHead>
|
||||
<TableRow className="bg-slate-50/50 hover:bg-slate-50/50">
|
||||
<TableHead className="font-bold text-slate-500 text-[10px] uppercase tracking-widest h-14 pl-8">Event ID / Category</TableHead>
|
||||
<TableHead className="font-bold text-slate-500 text-[10px] uppercase tracking-widest h-14">Actor Account</TableHead>
|
||||
<TableHead className="font-bold text-slate-500 text-[10px] uppercase tracking-widest h-14">Activity Description</TableHead>
|
||||
<TableHead className="font-bold text-slate-500 text-[10px] uppercase tracking-widest h-14">IP Address</TableHead>
|
||||
<TableHead className="font-bold text-slate-500 text-[10px] uppercase tracking-widest h-14 text-right pr-8">Timestamp</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{logs && logs.length > 0 ? (
|
||||
logs.map((log) => (
|
||||
<TableRow key={log.id} className="hover:bg-slate-50/50 transition-colors border-b border-slate-100">
|
||||
<TableRow key={log.id} className="hover:bg-slate-50/50 transition-colors">
|
||||
<TableCell className="py-4 pl-8">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge className={`border-none text-[8px] font-black uppercase tracking-wider ${
|
||||
@ -129,10 +137,8 @@ export default function AuditLogsHub({ logs = [], search = '', category = 'all'
|
||||
<span>{log.user}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs font-semibold text-slate-600 leading-normal">
|
||||
<div className="max-w-[420px] break-words whitespace-normal py-1">
|
||||
{log.action}
|
||||
</div>
|
||||
<TableCell className="text-xs font-semibold text-slate-600 leading-normal max-w-sm">
|
||||
{log.action}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs font-mono font-medium text-slate-400">
|
||||
<div className="flex items-center space-x-1">
|
||||
@ -140,14 +146,14 @@ export default function AuditLogsHub({ logs = [], search = '', category = 'all'
|
||||
<span>{log.ip_address}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-slate-400 text-right pr-8 font-semibold">{log.timestamp}</TableCell>
|
||||
<TableCell className="text-xs text-slate-400 text-right pr-8 font-mono">{log.timestamp}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="py-20 text-center">
|
||||
<Terminal className="w-12 h-12 text-slate-200 mx-auto mb-3" />
|
||||
<div className="font-bold text-slate-400 uppercase text-xs">No logs found.</div>
|
||||
<div className="font-bold text-slate-400">No security audit logs found matching your criteria.</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
|
||||
@ -13,401 +13,119 @@ import {
|
||||
ArrowUpRight,
|
||||
User,
|
||||
ShieldCheck,
|
||||
Send,
|
||||
Search,
|
||||
ChevronDown,
|
||||
MoreVertical,
|
||||
Clock,
|
||||
Eye,
|
||||
Plus,
|
||||
X,
|
||||
Folder,
|
||||
AlertTriangle,
|
||||
Hourglass
|
||||
Send
|
||||
} from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
export default function DisputesHub({ tickets }) {
|
||||
const [selectedTicket, setSelectedTicket] = useState(null);
|
||||
const [isWorkspaceOpen, setIsWorkspaceOpen] = useState(false);
|
||||
const [selectedTicket, setSelectedTicket] = useState(tickets[0] || null);
|
||||
|
||||
// Notes / Resolution states
|
||||
const [internalNote, setInternalNote] = useState('');
|
||||
const [resolutionAction, setResolutionAction] = useState('refund_employer');
|
||||
const [resolutionSummary, setResolutionSummary] = useState('');
|
||||
|
||||
// Filters
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('All Status');
|
||||
const [priorityFilter, setPriorityFilter] = useState('All Priority');
|
||||
const [typeFilter, setTypeFilter] = useState('All Types');
|
||||
const [sortBy, setSortBy] = useState('Newest First');
|
||||
|
||||
const handleAddNote = (e) => {
|
||||
e.preventDefault();
|
||||
if (!internalNote.trim() || !selectedTicket) return;
|
||||
if (!internalNote.trim()) return;
|
||||
|
||||
router.post(`/admin/disputes/${selectedTicket.id}/add-note`, { note: internalNote }, {
|
||||
onSuccess: () => {
|
||||
selectedTicket.admin_notes += "\n• " + internalNote;
|
||||
if (selectedTicket) {
|
||||
selectedTicket.admin_notes += "\n• " + internalNote;
|
||||
}
|
||||
setInternalNote('');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleResolve = (e) => {
|
||||
if (e) e.preventDefault();
|
||||
if (!selectedTicket) return;
|
||||
|
||||
e.preventDefault();
|
||||
router.post(`/admin/disputes/${selectedTicket.id}/resolve`, {
|
||||
resolution: internalNote || 'Resolved by mediator',
|
||||
action_taken: 'close_ticket'
|
||||
resolution: resolutionSummary,
|
||||
action_taken: resolutionAction
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
selectedTicket.status = 'Resolved';
|
||||
setIsWorkspaceOpen(false);
|
||||
setSelectedTicket(null);
|
||||
setInternalNote('');
|
||||
if (selectedTicket) {
|
||||
selectedTicket.status = 'Resolved';
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Filter tickets
|
||||
const filteredTickets = (tickets || []).filter(ticket => {
|
||||
// Status filter
|
||||
if (statusFilter !== 'All Status' && ticket.status !== statusFilter) return false;
|
||||
|
||||
// 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" />
|
||||
<AdminLayout title="Disputes Management Hub">
|
||||
<Head title="Disputes Management" />
|
||||
|
||||
<div className="font-sans max-w-[1600px] mx-auto space-y-6">
|
||||
<div className="font-sans max-w-7xl mx-auto space-y-6">
|
||||
|
||||
{/* Header Row */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 tracking-tight">Dispute Management</h1>
|
||||
<p className="text-sm text-slate-500 mt-1">Handle complaints and resolve disputes between workers and sponsors.</p>
|
||||
</div>
|
||||
|
||||
<button className="inline-flex items-center gap-1.5 px-4 py-2.5 bg-[#0F6E56] hover:bg-[#085041] text-white rounded-xl text-xs font-bold transition-all shadow-md self-end md:self-auto uppercase tracking-wider">
|
||||
<Plus className="w-4 h-4" />
|
||||
<span>New Dispute</span>
|
||||
</button>
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 tracking-tight">Contract & Payment Dispute Resolution Center</h1>
|
||||
<p className="text-xs text-gray-500 mt-0.5">Audit employer-worker chat history transcripts, review visa fee dispute receipts, and settle escrow refund transfers.</p>
|
||||
</div>
|
||||
|
||||
{/* Upper Row Metric Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 items-start">
|
||||
|
||||
{/* Card 1: Total Disputes */}
|
||||
<div className="bg-white p-5 border border-slate-200 rounded-2xl shadow-sm flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-slate-500 font-semibold block">Total Disputes</span>
|
||||
<span className="text-2xl font-extrabold text-slate-900 block">36</span>
|
||||
<span className="inline-flex items-center gap-0.5 px-2 py-0.5 bg-emerald-50 text-emerald-700 rounded-full text-[10px] font-bold">
|
||||
↑ 8% <span className="font-normal text-slate-400 ml-1">from last 30 days</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-3 bg-[#0F6E56]/10 text-[#0F6E56] rounded-2xl">
|
||||
<Scale className="w-6 h-6" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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"
|
||||
>
|
||||
<option value="All Status">All Status</option>
|
||||
<option value="Open">Open</option>
|
||||
<option value="Under Review">Under Review</option>
|
||||
<option value="Waiting Response">Waiting Response</option>
|
||||
<option value="Resolved">Resolved</option>
|
||||
</select>
|
||||
|
||||
|
||||
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={e => setTypeFilter(e.target.value)}
|
||||
className="bg-white border border-slate-200 text-slate-700 text-xs font-bold px-3 py-2 rounded-xl outline-none cursor-pointer hover:bg-slate-50 transition-colors shadow-sm"
|
||||
>
|
||||
<option value="All Types">All Types</option>
|
||||
<option value="Payment Issue">Payment Issue</option>
|
||||
<option value="Behavior Issue">Behavior Issue</option>
|
||||
<option value="Working Conditions">Working Conditions</option>
|
||||
<option value="Abuse/Harassment">Abuse/Harassment</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={e => setSortBy(e.target.value)}
|
||||
className="bg-white border border-slate-200 text-slate-700 text-xs font-bold px-3 py-2 rounded-xl outline-none cursor-pointer hover:bg-slate-50 transition-colors shadow-sm"
|
||||
>
|
||||
<option value="Newest First">Sort: Newest First</option>
|
||||
<option value="Oldest First">Sort: Oldest First</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table Row Content */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse text-xs font-semibold text-slate-600">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-100 bg-slate-50/50 text-slate-400 font-bold uppercase tracking-wider text-[10px]">
|
||||
<th className="px-5 py-4">Dispute ID</th>
|
||||
<th className="px-5 py-4">Raised By</th>
|
||||
<th className="px-5 py-4">Type</th>
|
||||
<th className="px-5 py-4">Reason</th>
|
||||
<th className="px-5 py-4">Status</th>
|
||||
<th className="px-5 py-4">Created At</th>
|
||||
<th className="px-5 py-4 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{filteredTickets.map((ticket) => (
|
||||
<tr key={ticket.id} className="hover:bg-slate-50/40 transition-colors">
|
||||
<td className="px-5 py-4 font-mono font-bold text-[#0F6E56] hover:underline cursor-pointer">
|
||||
{ticket.id}
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<img
|
||||
src={ticket.party_one_avatar || 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?auto=format&fit=crop&q=80&w=100'}
|
||||
alt={ticket.party_one_name}
|
||||
className="w-7 h-7 rounded-full object-cover border border-slate-200 shadow-sm"
|
||||
/>
|
||||
<div>
|
||||
<span className="font-extrabold text-slate-800 block">
|
||||
{ticket.party_one_name}
|
||||
</span>
|
||||
<span className="text-[10px] text-slate-400 font-bold block uppercase tracking-wider">
|
||||
{ticket.party_one_role}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<span className={`px-2.5 py-1 rounded-full text-[10px] font-black uppercase ${
|
||||
ticket.type === 'Payment Issue' ? 'bg-blue-100 text-blue-700' :
|
||||
ticket.type === 'Behavior Issue' ? 'bg-purple-100 text-purple-700' :
|
||||
ticket.type === 'Working Conditions' ? 'bg-emerald-100 text-emerald-700' : 'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
{ticket.type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-4 font-semibold text-slate-800">
|
||||
{ticket.reason}
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<span className={`px-2 py-0.5 rounded-full text-[10px] font-black uppercase ${
|
||||
ticket.status === 'Open' ? 'bg-orange-50 text-orange-700' :
|
||||
ticket.status === 'Under Review' ? 'bg-blue-50 text-blue-700' :
|
||||
ticket.status === 'Waiting Response' ? 'bg-purple-50 text-purple-700' : 'bg-emerald-50 text-emerald-700'
|
||||
}`}>
|
||||
{ticket.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-4 text-slate-400 font-semibold">
|
||||
{ticket.created_at}
|
||||
</td>
|
||||
<td className="px-5 py-4 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={() => { setSelectedTicket(ticket); setIsWorkspaceOpen(true); }}
|
||||
className="px-3 py-1.5 bg-white border border-slate-200 text-slate-700 rounded-lg hover:bg-slate-50 transition-colors shadow-sm font-bold uppercase tracking-wider text-[10px]"
|
||||
>
|
||||
View
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setSelectedTicket(ticket); setIsWorkspaceOpen(true); }}
|
||||
className="px-3.5 py-1.5 bg-[#0F6E56] hover:bg-[#085041] text-white rounded-lg transition-colors font-black uppercase tracking-wider text-[10px]"
|
||||
>
|
||||
Manage
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{/* Column 1: Disputes List */}
|
||||
<div className="space-y-4">
|
||||
<div className="bg-white p-4 border border-slate-200 rounded-2xl shadow-sm">
|
||||
<span className="text-[9px] font-black text-slate-400 uppercase tracking-widest block mb-3">Open Dispute Tickets</span>
|
||||
<div className="space-y-2">
|
||||
{tickets.map((ticket) => (
|
||||
<div
|
||||
key={ticket.id}
|
||||
onClick={() => setSelectedTicket(ticket)}
|
||||
className={`p-4 rounded-xl border cursor-pointer transition-all ${
|
||||
selectedTicket?.id === ticket.id
|
||||
? 'bg-emerald-50/50 border-[#0F6E56] shadow-sm'
|
||||
: 'bg-white border-slate-100 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="font-mono text-[9px] text-slate-400 font-bold">{ticket.id}</span>
|
||||
<Badge className={`border-none text-[8px] font-black uppercase ${
|
||||
ticket.status === 'Open' ? 'bg-amber-100 text-amber-700' :
|
||||
ticket.status === 'Resolved' ? 'bg-emerald-100 text-emerald-800' : 'bg-blue-100 text-blue-700'
|
||||
}`}>{ticket.status}</Badge>
|
||||
</div>
|
||||
<h4 className="text-xs font-black text-slate-900 tracking-tight leading-tight">{ticket.subject}</h4>
|
||||
<div className="flex items-center justify-between mt-3 text-[10px] text-slate-400 font-bold">
|
||||
<span>AED {ticket.amount_aed}</span>
|
||||
<span>{ticket.created_at}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{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>
|
||||
</div>
|
||||
|
||||
{/* Drawer Body Scroll Container */}
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-6">
|
||||
{/* Column 2: Audit Logs & Chat Logs */}
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
{selectedTicket ? (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
|
||||
{/* 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]" />
|
||||
{/* Chat Log Audit */}
|
||||
<div className="bg-white border border-slate-200 rounded-3xl p-5 shadow-sm flex flex-col justify-between h-[520px]">
|
||||
<div>
|
||||
<h3 className="text-xs font-black text-slate-800 uppercase tracking-widest flex items-center gap-1">
|
||||
<MessageSquare className="w-4 h-4 text-[#0F6E56]" />
|
||||
<span>Direct Chat logs Transcript</span>
|
||||
</h3>
|
||||
<p className="text-[10px] text-slate-400 font-semibold mt-1">Official authenticated conversation history audits</p>
|
||||
<p className="text-[9px] text-slate-400 font-semibold mt-1">Official authenticated conversation audit log</p>
|
||||
</div>
|
||||
|
||||
{/* Scroll thread */}
|
||||
<div className="flex-1 overflow-y-auto space-y-4 my-4 pr-1 scrollbar-thin">
|
||||
{/* Conversation thread */}
|
||||
<div className="flex-1 overflow-y-auto space-y-3 my-4 pr-2 max-h-[360px]">
|
||||
{selectedTicket.chat_logs.map((log, i) => {
|
||||
const isMediator = log.sender === 'Mediator (Admin)';
|
||||
const isPartyTwo = log.sender === selectedTicket.party_two_name;
|
||||
const isEmployer = log.sender === 'Employer';
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`max-w-[90%] p-3 rounded-2xl text-[11px] font-bold shadow-sm ${
|
||||
isMediator
|
||||
? 'bg-emerald-50 text-emerald-950 border border-emerald-250 mx-auto'
|
||||
: isPartyTwo
|
||||
? 'bg-blue-50 text-blue-900 mr-auto border border-blue-100'
|
||||
: 'bg-indigo-50 text-indigo-900 ml-auto border border-indigo-100'
|
||||
className={`max-w-[85%] p-3 rounded-2xl text-[11px] font-bold shadow-sm ${
|
||||
isEmployer
|
||||
? 'bg-blue-50 text-blue-900 mr-auto border border-blue-100'
|
||||
: 'bg-teal-50 text-teal-900 ml-auto border border-teal-100'
|
||||
}`}
|
||||
>
|
||||
<span className="text-[8px] font-black uppercase text-slate-400 block mb-1">{log.sender} • {log.time}</span>
|
||||
@ -417,80 +135,101 @@ export default function DisputesHub({ tickets }) {
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="text-[10px] bg-slate-50 p-2.5 rounded-xl border border-slate-100 text-slate-400 font-bold text-center uppercase tracking-wider">
|
||||
<div className="text-[10px] bg-slate-50 p-2.5 rounded-xl border border-slate-100 text-slate-400 font-semibold text-center uppercase tracking-wide">
|
||||
Logs Digitally Secured by UAE Vetting Compliance
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tools panel */}
|
||||
{/* Evidence & Resolution Tools */}
|
||||
<div className="space-y-6">
|
||||
|
||||
{/* Evidence Files */}
|
||||
<div className="bg-white border border-slate-200 rounded-2xl p-5 shadow-sm space-y-3">
|
||||
<h3 className="text-xs font-black text-slate-800 uppercase tracking-widest flex items-center gap-1.5">
|
||||
<FileText className="w-4.5 h-4.5 text-[#0F6E56]" />
|
||||
<span>Case Evidence Files</span>
|
||||
<div className="bg-white border border-slate-200 rounded-3xl p-5 shadow-sm space-y-3">
|
||||
<h3 className="text-xs font-black text-slate-800 uppercase tracking-widest flex items-center gap-1">
|
||||
<FileText className="w-4 h-4 text-[#0F6E56]" />
|
||||
<span>Evidence Files</span>
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{selectedTicket.evidence?.map((file, i) => (
|
||||
<div key={i} className="flex items-center justify-between p-2.5 bg-slate-50 rounded-xl border border-slate-150 text-xs font-bold text-slate-700 shadow-sm hover:bg-slate-100 transition-colors">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileText className="w-4 h-4 text-slate-400" />
|
||||
{selectedTicket.evidence?.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
{selectedTicket.evidence.map((file, i) => (
|
||||
<div key={i} className="flex items-center justify-between p-2.5 bg-slate-50 rounded-xl border text-xs font-bold text-slate-700">
|
||||
<span>{file.name} ({file.type})</span>
|
||||
<a href={file.url} className="text-[#0F6E56] hover:underline flex items-center gap-0.5">
|
||||
<span>View File</span>
|
||||
<ArrowUpRight className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
<a href={file.url} className="text-[#0F6E56] hover:underline flex items-center gap-0.5">
|
||||
<span>View</span>
|
||||
<ArrowUpRight className="w-3.5 h-3.5" />
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-[10px] text-slate-400 font-semibold uppercase">No attachment proofs uploaded.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Simplified Action Panel */}
|
||||
<div className="bg-white border border-slate-200 rounded-2xl p-5 shadow-sm space-y-4">
|
||||
<div>
|
||||
<h3 className="text-xs font-black text-slate-800 uppercase tracking-widest flex items-center gap-2">
|
||||
<MessageSquare className="w-4 h-4 text-[#0F6E56]" />
|
||||
<span>Mediator Reply & Actions</span>
|
||||
</h3>
|
||||
<p className="text-[10px] text-slate-400 font-semibold mt-1">Send a message to both parties or resolve the dispute.</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleAddNote} className="space-y-3">
|
||||
<textarea
|
||||
rows="3"
|
||||
className="w-full bg-white border border-slate-200 rounded-xl p-3 text-xs font-semibold focus:ring-2 focus:ring-[#0F6E56]/10 outline-none"
|
||||
placeholder="Write your response or internal compliance update..."
|
||||
{/* Admin Notes */}
|
||||
<div className="bg-yellow-50/50 border border-yellow-200 rounded-3xl p-5 shadow-sm space-y-3">
|
||||
<h3 className="text-xs font-black text-yellow-800 uppercase tracking-widest">Internal Compliance Notes</h3>
|
||||
<p className="text-[11px] text-slate-600 bg-white p-3 rounded-xl border border-yellow-100 font-semibold leading-relaxed whitespace-pre-line">
|
||||
{selectedTicket.admin_notes}
|
||||
</p>
|
||||
<form onSubmit={handleAddNote} className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
className="flex-1 bg-white border border-slate-200 rounded-xl px-3 py-2 text-xs font-bold focus:ring-2 focus:ring-yellow-500/10 outline-none"
|
||||
placeholder="Write compliance log note..."
|
||||
value={internalNote}
|
||||
onChange={e => setInternalNote(e.target.value)}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 py-2.5 bg-[#0F6E56] hover:bg-[#085041] text-white rounded-xl text-[10px] font-black uppercase tracking-wider transition-colors shadow-sm"
|
||||
>
|
||||
Send Reply
|
||||
</button>
|
||||
|
||||
{selectedTicket.status !== 'Resolved' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResolve}
|
||||
className="px-4 py-2.5 bg-red-600 hover:bg-red-700 text-white rounded-xl text-[10px] font-black uppercase tracking-wider transition-colors shadow-sm"
|
||||
>
|
||||
Close Ticket
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button type="submit" className="p-2 bg-[#0F6E56] text-white rounded-xl">
|
||||
<Send className="w-4.5 h-4.5" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Resolution Form */}
|
||||
{selectedTicket.status !== 'Resolved' && (
|
||||
<div className="bg-white border border-slate-200 rounded-3xl p-5 shadow-sm space-y-4">
|
||||
<div>
|
||||
<h3 className="text-xs font-black text-slate-800 uppercase tracking-widest">Settle Ticket Resolution</h3>
|
||||
<p className="text-[10px] text-slate-400 font-semibold mt-1">Submit official dispute settlement outcome</p>
|
||||
</div>
|
||||
<form onSubmit={handleResolve} className="space-y-3 text-xs font-bold text-slate-700">
|
||||
<div className="space-y-1">
|
||||
<label className="text-[9px] font-black text-slate-400 uppercase tracking-widest">Resolution Verdict</label>
|
||||
<select
|
||||
className="w-full bg-slate-50 border rounded-xl px-3 py-2.5 outline-none cursor-pointer focus:bg-white"
|
||||
value={resolutionAction}
|
||||
onChange={e => setResolutionAction(e.target.value)}
|
||||
>
|
||||
<option value="refund_employer">Approve Full Refund to Employer (AED {selectedTicket.amount_aed})</option>
|
||||
<option value="partial_refund">Approve 50% split partial refund</option>
|
||||
<option value="payout_worker">Dismiss dispute & release payout to worker</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<textarea
|
||||
rows="2"
|
||||
placeholder="Provide brief explanation for payment release/refund decisions..."
|
||||
className="w-full bg-white border rounded-xl p-3 font-semibold focus:ring-2 focus:ring-[#0F6E56]/10 outline-none"
|
||||
value={resolutionSummary}
|
||||
onChange={e => setResolutionSummary(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<button type="submit" className="w-full py-2.5 bg-slate-900 hover:bg-slate-800 text-white rounded-xl text-[10px] font-black uppercase tracking-widest shadow-md">
|
||||
Submit Official Verdict
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-white rounded-3xl border p-20 text-center text-slate-400 font-bold">
|
||||
Select a dispute ticket from the left panel.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@ -2,13 +2,21 @@ import React, { useState } from 'react';
|
||||
import { Head, router } from '@inertiajs/react';
|
||||
import AdminLayout from '@/Layouts/AdminLayout';
|
||||
import {
|
||||
BellRing,
|
||||
Send,
|
||||
Smartphone,
|
||||
MessageCircle,
|
||||
CheckCircle,
|
||||
BarChart3,
|
||||
Users,
|
||||
AlertTriangle,
|
||||
History,
|
||||
Sparkles
|
||||
Sparkles,
|
||||
TrendingUp
|
||||
} from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
export default function NotificationsCampaigns({ campaigns = [] }) {
|
||||
export default function NotificationsCampaigns({ campaigns, whatsapp_templates }) {
|
||||
const [channel, setChannel] = useState('push');
|
||||
const [recipients, setRecipients] = useState('All Active Workers');
|
||||
const [title, setTitle] = useState('');
|
||||
@ -30,121 +38,174 @@ export default function NotificationsCampaigns({ campaigns = [] }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminLayout title="Notifications">
|
||||
<Head title="Notifications" />
|
||||
<AdminLayout title="Campaigns & Notifications Hub">
|
||||
<Head title="Campaigns & Alerts" />
|
||||
|
||||
<div className="font-sans max-w-4xl mx-auto space-y-8 pb-12">
|
||||
<div className="font-sans max-w-7xl mx-auto space-y-8">
|
||||
|
||||
{/* Simplified Header */}
|
||||
<div className="border-b border-slate-200 pb-5">
|
||||
<h1 className="text-xl font-black text-slate-800 uppercase tracking-tight">Send Notification</h1>
|
||||
<p className="text-xs text-slate-500 mt-0.5 font-medium">Send quick push or WhatsApp alerts to workers and sponsors.</p>
|
||||
{/* Header overview and status */}
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900 tracking-tight">System Campaign Broadcasting Center</h1>
|
||||
<p className="text-xs text-gray-500 mt-0.5">Reach users directly via SMS, WhatsApp, and Native Push notifications. Monitor open rates and clicks.</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-8">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 items-start">
|
||||
|
||||
{/* Message Composer */}
|
||||
<div className="bg-white border border-slate-200 rounded-3xl p-6 shadow-sm">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
{/* Column 1 & 2: Broadcast Composer & Campaigns History */}
|
||||
<div className="lg:col-span-2 space-y-8">
|
||||
|
||||
{/* Section 1: Composer */}
|
||||
<div className="bg-white border border-slate-200 rounded-3xl p-6 shadow-sm">
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-black text-slate-800 uppercase tracking-widest flex items-center gap-1">
|
||||
<Sparkles className="w-4 h-4 text-[#0F6E56]" />
|
||||
<span>Direct Campaign Composer</span>
|
||||
</h3>
|
||||
<p className="text-[10px] text-slate-400 font-semibold mt-1">Schedules immediate or recurring broadcasts</p>
|
||||
</div>
|
||||
<Badge className="bg-emerald-50 text-emerald-700 border-none font-bold uppercase text-[8px] tracking-wider">SMS Gateway: Active</Badge>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleBroadcast} className="space-y-4 text-xs font-bold text-slate-700">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Select Channel</label>
|
||||
<select
|
||||
className="w-full bg-slate-50 border rounded-xl px-3 py-2.5 outline-none cursor-pointer focus:bg-white"
|
||||
value={channel}
|
||||
onChange={e => setChannel(e.target.value)}
|
||||
>
|
||||
<option value="push">Native App Push Notification</option>
|
||||
<option value="whatsapp">WhatsApp Business API</option>
|
||||
<option value="both">Both (Multi-channel coverage)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Target User Cohort</label>
|
||||
<select
|
||||
className="w-full bg-slate-50 border rounded-xl px-3 py-2.5 outline-none cursor-pointer focus:bg-white"
|
||||
value={recipients}
|
||||
onChange={e => setRecipients(e.target.value)}
|
||||
>
|
||||
<option value="All Active Workers">All Active Workers (1,250 users)</option>
|
||||
<option value="Unverified Workers (Morocco & Philippines)">Unverified Workers Pool (142 users)</option>
|
||||
<option value="Premium Employers">Premium Employers only (312 users)</option>
|
||||
<option value="All Employers">All Registered Employers (380 users)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Notification Title</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
className="w-full bg-slate-50 border rounded-xl px-3 py-2.5 outline-none focus:bg-white focus:ring-2 focus:ring-[#0F6E56]/10"
|
||||
placeholder="e.g., Emirates ID Verification Required"
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Notification Text Message</label>
|
||||
<textarea
|
||||
rows="3"
|
||||
required
|
||||
className="w-full bg-slate-50 border rounded-xl p-3 outline-none focus:bg-white focus:ring-2 focus:ring-[#0F6E56]/10"
|
||||
placeholder="Type the message content. Use tags like {{name}} to personalize matches..."
|
||||
value={message}
|
||||
onChange={e => setMessage(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="w-full py-3 bg-[#0F6E56] text-white rounded-xl text-[10px] font-black uppercase tracking-widest shadow-md hover:bg-[#085041] flex items-center justify-center gap-1.5">
|
||||
<Send className="w-4 h-4" />
|
||||
<span>Broadcast System Campaign</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Section 2: Campaigns History */}
|
||||
<div className="bg-white border border-slate-200 rounded-3xl p-6 shadow-sm overflow-hidden">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-sm font-black text-slate-800 uppercase tracking-widest flex items-center gap-1.5">
|
||||
<Sparkles className="w-4 h-4 text-[#0F6E56]" />
|
||||
<span>New Notification Alert</span>
|
||||
<History className="w-4 h-4 text-slate-600" />
|
||||
<span>Broadcast Campaign Logs</span>
|
||||
</h3>
|
||||
<p className="text-[10px] text-slate-400 font-semibold mt-1">Send a dynamic message immediately</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleBroadcast} className="space-y-4 text-xs font-bold text-slate-700">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Message Channel</label>
|
||||
<select
|
||||
className="w-full bg-slate-50 border rounded-xl px-3 py-2.5 outline-none cursor-pointer focus:bg-white"
|
||||
value={channel}
|
||||
onChange={e => setChannel(e.target.value)}
|
||||
>
|
||||
<option value="push">App Message</option>
|
||||
<option value="whatsapp">WhatsApp Message</option>
|
||||
<option value="both">Both Channels</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Send To</label>
|
||||
<select
|
||||
className="w-full bg-slate-50 border rounded-xl px-3 py-2.5 outline-none cursor-pointer focus:bg-white"
|
||||
value={recipients}
|
||||
onChange={e => setRecipients(e.target.value)}
|
||||
>
|
||||
<option value="All Active Workers">All Workers</option>
|
||||
<option value="All Employers">All Sponsors</option>
|
||||
</select>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 font-semibold mt-0.5">Logs of recently delivered campaigns</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Message Title</label>
|
||||
<input
|
||||
type="text"
|
||||
required
|
||||
className="w-full bg-slate-50 border rounded-xl px-3 py-2.5 outline-none focus:bg-white focus:ring-2 focus:ring-[#0F6E56]/10"
|
||||
placeholder="e.g. Account Update Alert"
|
||||
value={title}
|
||||
onChange={e => setTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest">Message Text</label>
|
||||
<textarea
|
||||
rows="3"
|
||||
required
|
||||
className="w-full bg-slate-50 border rounded-xl p-3 outline-none focus:bg-white focus:ring-2 focus:ring-[#0F6E56]/10"
|
||||
placeholder="Type the message..."
|
||||
value={message}
|
||||
onChange={e => setMessage(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" className="w-full py-3 bg-[#0F6E56] text-white rounded-xl text-[10px] font-black uppercase tracking-widest shadow-md hover:bg-[#085041] flex items-center justify-center gap-1.5">
|
||||
<Send className="w-4 h-4" />
|
||||
<span>Send Message</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Recently Sent Logs */}
|
||||
<div className="bg-white border border-slate-200 rounded-3xl p-6 shadow-sm overflow-hidden">
|
||||
<div className="mb-4">
|
||||
<h3 className="text-sm font-black text-slate-800 uppercase tracking-widest flex items-center gap-1.5">
|
||||
<History className="w-4 h-4 text-slate-600" />
|
||||
<span>History</span>
|
||||
</h3>
|
||||
<p className="text-[10px] text-slate-400 font-semibold mt-0.5">Logs of recently sent alerts</p>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-slate-100">
|
||||
{campaigns.map((camp) => (
|
||||
<div key={camp.id} className="py-4 hover:bg-slate-50/50 transition-colors flex items-start justify-between gap-4 text-xs font-bold text-slate-700">
|
||||
<div className="space-y-1.5 flex-1">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge className="bg-[#0F6E56]/10 text-[#0F6E56] border-none font-bold uppercase text-[8px]">{camp.channel}</Badge>
|
||||
<span className="text-[10px] text-slate-400 font-semibold">To: {camp.recipient_type}</span>
|
||||
<div className="divide-y divide-slate-100">
|
||||
{campaigns.map((camp) => (
|
||||
<div key={camp.id} className="py-4 hover:bg-slate-50/50 transition-colors flex items-start justify-between gap-4 text-xs font-bold text-slate-700">
|
||||
<div className="space-y-1.5 flex-1">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge className="bg-blue-50 text-blue-700 border-none font-bold uppercase text-[8px]">{camp.channel}</Badge>
|
||||
<span className="text-[10px] text-slate-400 font-semibold">Cohort: {camp.recipient_type}</span>
|
||||
</div>
|
||||
<h4 className="text-sm font-black text-slate-900">{camp.title}</h4>
|
||||
<p className="text-slate-500 font-medium text-[11px] leading-relaxed">{camp.message}</p>
|
||||
</div>
|
||||
<h4 className="text-sm font-black text-slate-900">{camp.title}</h4>
|
||||
<p className="text-slate-500 font-medium text-[11px] leading-relaxed">{camp.message}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<span className="text-[9px] text-slate-400 font-mono block">{camp.sent_at}</span>
|
||||
<div className="mt-2 text-[10px] font-black text-emerald-600 uppercase tracking-wide">
|
||||
Sent Successfully
|
||||
<div className="text-right">
|
||||
<span className="text-[9px] text-slate-400 font-mono block">{camp.sent_at}</span>
|
||||
<div className="mt-2 text-[10px] font-black text-emerald-600 uppercase tracking-wide">
|
||||
{camp.delivery_rate} Delivered • {camp.clicks}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</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>
|
||||
</AdminLayout>
|
||||
|
||||
@ -13,14 +13,7 @@ import {
|
||||
UserX,
|
||||
Settings,
|
||||
FileText,
|
||||
MessageSquare,
|
||||
Bell,
|
||||
Calendar,
|
||||
Search,
|
||||
ChevronDown,
|
||||
MoreVertical,
|
||||
Clock,
|
||||
Shield
|
||||
MessageSquare
|
||||
} from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
@ -36,352 +29,142 @@ export default function SafetyHub({ reports, rules, moderation_queue }) {
|
||||
const [resolutionAction, setResolutionAction] = useState('warn');
|
||||
const [adminNotes, setAdminNotes] = useState('');
|
||||
|
||||
// Tab and filter states
|
||||
const [activeTab, setActiveTab] = useState('All Reports');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('All Status');
|
||||
const [typeFilter, setTypeFilter] = useState('All Types');
|
||||
|
||||
const handleResolve = (e) => {
|
||||
e.preventDefault();
|
||||
if (!selectedReport) return;
|
||||
|
||||
router.post(`/admin/safety/reports/${selectedReport.id}/resolve`, {
|
||||
action: resolutionAction,
|
||||
admin_notes: adminNotes
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
setIsDialogOpen(false);
|
||||
setSelectedReport(null);
|
||||
setAdminNotes('');
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
// Filter reports based on active tab, search query, type, and status
|
||||
const filteredReports = (reports || []).filter(report => {
|
||||
// Tab filter
|
||||
if (activeTab !== 'All Reports') {
|
||||
if (activeTab === 'Profiles' && report.type !== 'Profile') return false;
|
||||
if (activeTab === 'Chats' && report.type !== 'Chat') return false;
|
||||
if (activeTab === 'Reviews' && report.type !== 'Review') return false;
|
||||
if (activeTab === 'Images' && report.type !== 'Image') return false;
|
||||
if (activeTab === 'Other Content' && report.type === 'Profile' || report.type === 'Chat' || report.type === 'Review' || report.type === 'Image') return false;
|
||||
}
|
||||
|
||||
// Type filter dropdown
|
||||
if (typeFilter !== 'All Types' && report.type !== typeFilter) return false;
|
||||
|
||||
// Status filter dropdown
|
||||
if (statusFilter !== 'All Status' && report.status !== statusFilter) return false;
|
||||
|
||||
// Search query
|
||||
if (searchQuery.trim() !== '') {
|
||||
const query = searchQuery.toLowerCase();
|
||||
return (
|
||||
report.id.toLowerCase().includes(query) ||
|
||||
report.reason.toLowerCase().includes(query) ||
|
||||
report.reported_user_name.toLowerCase().includes(query) ||
|
||||
report.reported_by_name.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
const handleModQueue = (id, action) => {
|
||||
router.post(`/admin/safety/reports/${id}/resolve`, { action }, {
|
||||
onSuccess: () => {
|
||||
setIsDialogOpen(false);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminLayout title="Content Moderation">
|
||||
<Head title="Content Moderation" />
|
||||
<AdminLayout title="Safety & Moderation Center">
|
||||
<Head title="Safety & Moderation" />
|
||||
|
||||
<div className="font-sans max-w-[1600px] mx-auto space-y-6">
|
||||
<div className="font-sans max-w-7xl mx-auto space-y-8">
|
||||
|
||||
{/* Header Row */}
|
||||
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-slate-900 tracking-tight">Content Moderation</h1>
|
||||
<p className="text-sm text-slate-500 mt-1">Review and take action on reported content, users and reviews.</p>
|
||||
<h1 className="text-xl font-bold text-gray-900 tracking-tight">Trust & Safety Escalation Center</h1>
|
||||
<p className="text-xs text-gray-500 mt-0.5">Manage user abuse reports, content moderation queues, and customize automatic fraud filters.</p>
|
||||
</div>
|
||||
|
||||
{/* Header Controls */}
|
||||
<div className="flex items-center gap-3 self-end md:self-auto">
|
||||
<button className="relative p-2.5 bg-white border border-slate-200 rounded-xl hover:bg-slate-50 transition-colors shadow-sm text-slate-600">
|
||||
<Bell className="w-5 h-5" />
|
||||
<span className="absolute top-1.5 right-1.5 w-4 h-4 bg-red-500 text-white rounded-full text-[9px] font-bold flex items-center justify-center">12</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<button className="inline-flex items-center px-4 py-2.5 bg-white border border-gray-200 text-[#0F6E56] rounded-xl text-xs font-bold hover:bg-gray-50 transition-colors shadow-sm space-x-2 uppercase tracking-wider">
|
||||
<Settings className="w-4 h-4" />
|
||||
<span>System Rule Rules</span>
|
||||
</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>
|
||||
|
||||
{/* Upper Row Metric Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||||
|
||||
{/* Card 1: Total Reports */}
|
||||
<div className="bg-white p-5 border border-slate-200 rounded-2xl shadow-sm flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-slate-500 font-semibold block">Total Reports</span>
|
||||
<span className="text-2xl font-extrabold text-slate-900 block">128</span>
|
||||
<span className="inline-flex items-center gap-0.5 px-2 py-0.5 bg-emerald-50 text-emerald-700 rounded-full text-[10px] font-bold">
|
||||
↑ 12% <span className="font-normal text-slate-400 ml-1">from last 30 days</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="p-3 bg-[#0F6E56]/10 text-[#0F6E56] rounded-2xl">
|
||||
<Flag className="w-6 h-6" />
|
||||
</div>
|
||||
</div>
|
||||
{/* Column 1 & 2: Abuse Reports & Content Queue */}
|
||||
<div className="lg:col-span-2 space-y-8">
|
||||
|
||||
{/* Card 2: Pending Review */}
|
||||
<div className="bg-white p-5 border border-slate-200 rounded-2xl shadow-sm flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-slate-500 font-semibold block">Pending Review</span>
|
||||
<span className="text-2xl font-extrabold text-slate-900 block">42</span>
|
||||
<span className="text-[11px] text-amber-600 font-bold block">Requires attention</span>
|
||||
</div>
|
||||
<div className="p-3 bg-amber-50 text-amber-600 rounded-2xl">
|
||||
<Clock className="w-6 h-6" />
|
||||
</div>
|
||||
</div>
|
||||
{/* Section 1: Abuse Reports */}
|
||||
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden">
|
||||
<div className="p-5 border-b border-slate-100 flex items-center justify-between bg-slate-50/20">
|
||||
<div>
|
||||
<h3 className="text-sm font-black text-slate-800 uppercase tracking-widest">Active Incident Reports</h3>
|
||||
<p className="text-[10px] text-slate-400 mt-0.5">Escalated complaints filed by employers or workers</p>
|
||||
</div>
|
||||
<Badge className="bg-red-50 text-red-700 border-none font-bold uppercase tracking-wider text-[8px]">{reports?.length || 0} Open Tickets</Badge>
|
||||
</div>
|
||||
|
||||
{/* Card 4: Resolved */}
|
||||
<div className="bg-white p-5 border border-slate-200 rounded-2xl shadow-sm flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-slate-500 font-semibold block">Resolved</span>
|
||||
<span className="text-2xl font-extrabold text-slate-900 block">78</span>
|
||||
<span className="text-[11px] text-emerald-600 font-bold block">This period</span>
|
||||
</div>
|
||||
<div className="p-3 bg-emerald-50 text-emerald-600 rounded-2xl">
|
||||
<CheckCircle className="w-6 h-6" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card 5: Actions Taken */}
|
||||
<div className="bg-white p-5 border border-slate-200 rounded-2xl shadow-sm flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<span className="text-xs text-slate-500 font-semibold block">Actions Taken</span>
|
||||
<span className="text-2xl font-extrabold text-slate-900 block">56</span>
|
||||
<span className="text-[11px] text-blue-600 font-bold block">Warnings/Suspensions</span>
|
||||
</div>
|
||||
<div className="p-3 bg-blue-50 text-blue-600 rounded-2xl">
|
||||
<Shield className="w-6 h-6" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs & Controls Section */}
|
||||
<div className="bg-white border border-slate-200 rounded-2xl shadow-sm overflow-hidden">
|
||||
<div className="p-4 border-b border-slate-100 flex flex-col lg:flex-row lg:items-center justify-between gap-4 bg-slate-50/20">
|
||||
{/* Tabs list */}
|
||||
<div className="flex items-center overflow-x-auto gap-2 -mb-4 lg:mb-0 pb-2 lg:pb-0 scrollbar-none">
|
||||
{['All Reports', 'Profiles', 'Chats', 'Reviews'].map((tab) => (
|
||||
<button
|
||||
key={tab}
|
||||
onClick={() => setActiveTab(tab)}
|
||||
className={`px-4 py-2 text-xs font-bold whitespace-nowrap transition-all rounded-lg ${
|
||||
activeTab === tab
|
||||
? 'bg-[#0F6E56]/10 text-[#0F6E56] font-black shadow-sm'
|
||||
: 'text-slate-500 hover:text-slate-800'
|
||||
}`}
|
||||
>
|
||||
{tab}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 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 className="divide-y divide-slate-100">
|
||||
{reports.map((report) => (
|
||||
<div key={report.id} className="p-5 hover:bg-slate-50/50 transition-colors flex items-start justify-between gap-4 text-xs font-bold text-slate-700">
|
||||
<div className="space-y-2 flex-1">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge className={`border-none text-[8px] font-black uppercase ${
|
||||
report.severity === 'High' ? 'bg-red-100 text-red-700' :
|
||||
report.severity === 'Medium' ? 'bg-amber-100 text-amber-700' : 'bg-slate-100 text-slate-700'
|
||||
}`}>
|
||||
{report.severity} Severity
|
||||
</Badge>
|
||||
<span className="text-[10px] text-slate-400 font-mono">{report.id}</span>
|
||||
<span className="text-slate-300">•</span>
|
||||
<span className="text-slate-400">Reporter: {report.reporter}</span>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="text-sm font-black text-slate-900">{report.reason}</h4>
|
||||
<p className="text-slate-500 font-medium mt-1 leading-relaxed">{report.description}</p>
|
||||
</div>
|
||||
<div className="text-[10px] text-slate-400 font-semibold uppercase">Reported User: <span className="text-slate-900 font-bold">{report.reported_user}</span></div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<span className="text-[9px] text-slate-400 font-mono">{report.created_at}</span>
|
||||
<button
|
||||
onClick={() => { setSelectedReport(report); setIsDialogOpen(true); }}
|
||||
className="px-3.5 py-1.5 bg-slate-900 hover:bg-slate-800 text-white rounded-lg font-black text-[9px] uppercase tracking-wider"
|
||||
>
|
||||
Investigate
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table Row Content */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse text-xs font-semibold text-slate-600">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-100 bg-slate-50/50 text-slate-400 font-bold uppercase tracking-wider text-[10px]">
|
||||
<th className="px-5 py-4 w-10">
|
||||
<input type="checkbox" className="rounded border-slate-300 text-[#0F6E56] focus:ring-[#0F6E56] cursor-pointer" />
|
||||
</th>
|
||||
<th className="px-5 py-4">Report ID</th>
|
||||
<th className="px-5 py-4">Type</th>
|
||||
<th className="px-5 py-4">Reported User</th>
|
||||
<th className="px-5 py-4">Reported By</th>
|
||||
<th className="px-5 py-4">Reason</th>
|
||||
<th className="px-5 py-4">Status</th>
|
||||
<th className="px-5 py-4">Reported At</th>
|
||||
<th className="px-5 py-4 text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{filteredReports.map((report) => (
|
||||
<tr key={report.id} className="hover:bg-slate-50/40 transition-colors">
|
||||
<td className="px-5 py-4">
|
||||
<input type="checkbox" className="rounded border-slate-300 text-[#0F6E56] focus:ring-[#0F6E56] cursor-pointer" />
|
||||
</td>
|
||||
<td className="px-5 py-4 font-mono font-bold text-[#0F6E56] hover:underline cursor-pointer">
|
||||
{report.id}
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<span className={`px-2.5 py-1 rounded-full text-[10px] font-black uppercase ${
|
||||
report.type === 'Profile' ? 'bg-purple-100 text-purple-700' :
|
||||
report.type === 'Chat' ? 'bg-cyan-100 text-cyan-700' :
|
||||
report.type === 'Review' ? 'bg-emerald-100 text-emerald-700' : 'bg-orange-100 text-orange-700'
|
||||
}`}>
|
||||
{report.type}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<img
|
||||
src={report.reported_user_avatar || 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?auto=format&fit=crop&q=80&w=100'}
|
||||
alt={report.reported_user_name}
|
||||
className="w-7 h-7 rounded-full object-cover shadow-sm border border-slate-100"
|
||||
/>
|
||||
<div>
|
||||
<span className="font-extrabold text-slate-800 block">{report.reported_user_name}</span>
|
||||
<span className="text-[10px] text-slate-400 font-semibold block">{report.reported_user_role}</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<img
|
||||
src={report.reported_by_avatar || 'https://images.unsplash.com/photo-1544005313-94ddf0286df2?auto=format&fit=crop&q=80&w=100'}
|
||||
alt={report.reported_by_name}
|
||||
className="w-7 h-7 rounded-full object-cover shadow-sm border border-slate-100"
|
||||
/>
|
||||
<div>
|
||||
<span className="font-extrabold text-slate-800 block">{report.reported_by_name}</span>
|
||||
<span className="text-[10px] text-slate-400 font-semibold block">{report.reported_by_role}</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-5 py-4 font-semibold text-slate-800">
|
||||
{report.reason}
|
||||
</td>
|
||||
<td className="px-5 py-4">
|
||||
<span className={`px-2 py-0.5 rounded-full text-[10px] font-black uppercase ${
|
||||
report.status === 'Pending' ? 'bg-orange-50 text-orange-700' :
|
||||
report.status === 'In Review' ? 'bg-blue-50 text-blue-700' : 'bg-emerald-50 text-emerald-700'
|
||||
}`}>
|
||||
{report.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-5 py-4 text-slate-400 font-semibold">
|
||||
{report.reported_at}
|
||||
</td>
|
||||
<td className="px-5 py-4 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={() => { setSelectedReport(report); setIsDialogOpen(true); }}
|
||||
className="px-3 py-1.5 bg-white border border-slate-200 text-slate-700 rounded-lg hover:bg-slate-50 transition-colors shadow-sm font-bold uppercase tracking-wider text-[10px]"
|
||||
>
|
||||
View
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setSelectedReport(report); setIsDialogOpen(true); }}
|
||||
className="px-3.5 py-1.5 bg-[#0F6E56] hover:bg-[#085041] text-white rounded-lg transition-colors font-black uppercase tracking-wider text-[10px]"
|
||||
>
|
||||
Take Action
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
{filteredReports.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan="10" className="p-10 text-center font-bold text-slate-400 uppercase">
|
||||
No incident reports found matching your criteria.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subsections: Vetting Queue & Auto-Detection Rules */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
{/* Content Vetting Queue */}
|
||||
<div className="lg:col-span-2 bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden">
|
||||
<div className="p-5 border-b border-slate-100 flex items-center justify-between bg-slate-50/20">
|
||||
<div>
|
||||
{/* Section 2: Content Moderation Queue */}
|
||||
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden">
|
||||
<div className="p-5 border-b border-slate-100 bg-slate-50/20">
|
||||
<h3 className="text-sm font-black text-slate-800 uppercase tracking-widest">Profile Content Vetting Queue</h3>
|
||||
<p className="text-[10px] text-slate-400 mt-0.5">Bio updates or pictures flagged for manual review</p>
|
||||
</div>
|
||||
<Badge className="bg-amber-50 text-amber-700 border-none font-bold uppercase tracking-wider text-[8px]">Action Required</Badge>
|
||||
|
||||
<div className="divide-y divide-slate-100">
|
||||
{moderation_queue.map((item) => (
|
||||
<div key={item.id} className="p-5 hover:bg-slate-50/30 transition-colors grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-bold text-slate-700">
|
||||
<div>
|
||||
<span className="text-[9px] text-slate-400 uppercase tracking-widest block font-mono">{item.id} • {item.type}</span>
|
||||
<span className="text-sm font-black text-slate-900 block mt-1">{item.user}</span>
|
||||
<Badge className="mt-2 bg-amber-50 text-amber-700 border-none font-bold uppercase tracking-wider text-[8px]">{item.flag}</Badge>
|
||||
</div>
|
||||
<div className="sm:col-span-2 flex flex-col justify-between items-end gap-3">
|
||||
<div className="bg-slate-50 p-3 rounded-xl border border-slate-100 text-slate-600 font-medium w-full text-[11px] leading-relaxed">
|
||||
{item.content.startsWith('http') ? (
|
||||
<img src={item.content} alt="Flagged asset" className="h-16 rounded border" />
|
||||
) : item.content}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => handleModQueue(item.id, 'approve')}
|
||||
className="px-3.5 py-1.5 bg-emerald-600 hover:bg-emerald-500 text-white rounded-lg text-[9px] font-black uppercase tracking-wider"
|
||||
>
|
||||
Approve Content
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleModQueue(item.id, 'reject')}
|
||||
className="px-3.5 py-1.5 bg-red-600 hover:bg-red-500 text-white rounded-lg text-[9px] font-black uppercase tracking-wider"
|
||||
>
|
||||
Reject / Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-slate-100">
|
||||
{moderation_queue.map((item) => (
|
||||
<div key={item.id} className="p-5 hover:bg-slate-50/20 transition-colors grid grid-cols-1 sm:grid-cols-3 gap-4 text-xs font-bold text-slate-700">
|
||||
<div>
|
||||
<span className="text-[9px] text-slate-400 uppercase tracking-widest block font-mono">{item.id} • {item.type}</span>
|
||||
<span className="text-sm font-black text-slate-900 block mt-1">{item.user}</span>
|
||||
<Badge className="mt-2 bg-red-50 text-red-700 border-none font-bold uppercase tracking-wider text-[8px]">{item.flag}</Badge>
|
||||
</div>
|
||||
<div className="sm:col-span-2 flex flex-col justify-between items-end gap-3">
|
||||
<div className="bg-slate-50 p-3 rounded-xl border border-slate-100 text-slate-600 font-medium w-full text-[11px] leading-relaxed">
|
||||
{item.content.startsWith('http') ? (
|
||||
<img src={item.content} alt="Flagged asset" className="h-16 rounded border" />
|
||||
) : item.content}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button className="px-3.5 py-1.5 bg-emerald-600 hover:bg-emerald-500 text-white rounded-lg text-[9px] font-black uppercase tracking-wider transition-colors shadow-sm">
|
||||
Approve Content
|
||||
</button>
|
||||
<button className="px-3.5 py-1.5 bg-red-600 hover:bg-red-500 text-white rounded-lg text-[9px] font-black uppercase tracking-wider transition-colors shadow-sm">
|
||||
Reject / Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Auto-Detection Rules */}
|
||||
<div className="bg-white p-5 border border-slate-200 rounded-2xl shadow-sm flex flex-col justify-between">
|
||||
<div className="space-y-4">
|
||||
{/* Column 3: Auto-Flag Detection Rules */}
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white p-5 border border-slate-200 rounded-2xl shadow-sm space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-black text-slate-800 uppercase tracking-widest">Auto-Detection Rules</h3>
|
||||
<p className="text-[10px] text-slate-400 mt-0.5">Parameters triggering system automated flags</p>
|
||||
@ -400,21 +183,30 @@ export default function SafetyHub({ reports, rules, moderation_queue }) {
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button className="w-full py-3 bg-[#0F6E56] text-white rounded-xl text-[10px] font-black uppercase tracking-widest shadow-md hover:bg-[#085041] flex items-center justify-center gap-1.5">
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>Create Custom Rule</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button className="w-full mt-4 py-3 bg-[#0F6E56] text-white rounded-xl text-[10px] font-black uppercase tracking-widest shadow-md hover:bg-[#085041] flex items-center justify-center gap-1.5 transition-colors">
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
<span>Create Custom Rule</span>
|
||||
</button>
|
||||
{/* 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>
|
||||
|
||||
{/* Investigation & Action Dialog Modal */}
|
||||
{/* Investigation Dialog Modal */}
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<DialogContent className="max-w-xl bg-white p-6 rounded-[24px] border-none shadow-2xl font-sans">
|
||||
<DialogHeader className="mb-4">
|
||||
<DialogTitle className="text-lg font-black text-slate-800 flex items-center gap-2">
|
||||
<DialogTitle className="text-lg font-black text-slate-800 flex items-center gap-1">
|
||||
<ShieldAlert className="w-5 h-5 text-red-600" />
|
||||
<span>Incident Investigation: <span className="font-mono text-slate-400">{selectedReport?.id}</span></span>
|
||||
</DialogTitle>
|
||||
@ -423,20 +215,18 @@ export default function SafetyHub({ reports, rules, moderation_queue }) {
|
||||
<form onSubmit={handleResolve} className="space-y-4 text-xs font-bold text-slate-700">
|
||||
<div className="space-y-1">
|
||||
<label className="text-[9px] font-black text-slate-400 uppercase tracking-widest">Reason / Incident Subject</label>
|
||||
<div className="p-3 bg-slate-50 border border-slate-200 rounded-xl font-black text-slate-800">{selectedReport?.reason}</div>
|
||||
<div className="p-3 bg-slate-50 border rounded-xl font-black text-slate-800">{selectedReport?.reason}</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-[9px] font-black text-slate-400 uppercase tracking-widest">Report Description</label>
|
||||
<p className="p-3 bg-slate-50 border border-slate-200 rounded-xl font-medium text-slate-600 leading-relaxed">
|
||||
{selectedReport?.description || 'No description details provided.'}
|
||||
</p>
|
||||
<p className="p-3 bg-slate-50 border rounded-xl font-medium text-slate-600 leading-relaxed">{selectedReport?.description}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<label className="text-[9px] font-black text-slate-400 uppercase tracking-widest">Select Resolution Action</label>
|
||||
<select
|
||||
className="w-full bg-slate-50 border border-slate-200 rounded-xl px-3 py-2.5 font-bold outline-none cursor-pointer focus:bg-white transition-colors"
|
||||
className="w-full bg-slate-50 border rounded-xl px-3 py-2.5 font-bold outline-none cursor-pointer focus:bg-white"
|
||||
value={resolutionAction}
|
||||
onChange={e => setResolutionAction(e.target.value)}
|
||||
>
|
||||
@ -452,7 +242,7 @@ export default function SafetyHub({ reports, rules, moderation_queue }) {
|
||||
<textarea
|
||||
rows="3"
|
||||
placeholder="State findings of direct message logs audits or phone outreach validation..."
|
||||
className="w-full bg-white border border-slate-200 rounded-xl p-3 font-semibold focus:ring-2 focus:ring-[#0F6E56]/10 outline-none"
|
||||
className="w-full bg-white border rounded-xl p-3 font-semibold focus:ring-2 focus:ring-[#0F6E56]/10 outline-none"
|
||||
value={adminNotes}
|
||||
onChange={e => setAdminNotes(e.target.value)}
|
||||
/>
|
||||
@ -461,14 +251,14 @@ export default function SafetyHub({ reports, rules, moderation_queue }) {
|
||||
<div className="flex gap-2 pt-2 justify-end">
|
||||
<button
|
||||
type="submit"
|
||||
className="px-5 py-2.5 bg-[#0F6E56] hover:bg-[#085041] text-white rounded-xl text-[10px] font-black uppercase tracking-widest transition-colors shadow-md"
|
||||
className="px-5 py-2.5 bg-slate-900 hover:bg-slate-800 text-white rounded-xl text-[10px] font-black uppercase tracking-widest"
|
||||
>
|
||||
Submit Findings
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsDialogOpen(false)}
|
||||
className="px-4 py-2.5 bg-slate-100 hover:bg-slate-200 text-slate-700 rounded-xl text-[10px] font-black uppercase tracking-widest transition-colors"
|
||||
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"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
|
||||
@ -2,15 +2,21 @@ import React, { useState } from 'react';
|
||||
import { Head, router } from '@inertiajs/react';
|
||||
import AdminLayout from '@/Layouts/AdminLayout';
|
||||
import {
|
||||
Search,
|
||||
FileText,
|
||||
Eye,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Clock,
|
||||
Edit2,
|
||||
ZoomIn,
|
||||
X,
|
||||
AlertTriangle,
|
||||
ShieldCheck,
|
||||
Edit3,
|
||||
History,
|
||||
Sparkles
|
||||
Sparkles,
|
||||
CheckSquare
|
||||
} from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
@ -23,6 +29,7 @@ import {
|
||||
export default function Verifications({ verifications, status }) {
|
||||
const [selectedItem, setSelectedItem] = useState(null);
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
const [lightboxImage, setLightboxImage] = useState(null);
|
||||
|
||||
// Vetting form overrides
|
||||
const [ocrOverrides, setOcrOverrides] = useState({});
|
||||
@ -30,7 +37,7 @@ export default function Verifications({ verifications, status }) {
|
||||
const [rejectionReason, setRejectionReason] = useState('');
|
||||
const [showRejectionForm, setShowRejectionForm] = useState(false);
|
||||
|
||||
// Local queue of workers to verify
|
||||
// Local mock verification queue containing OCR score mismatches
|
||||
const localQueue = [
|
||||
{
|
||||
id: 101,
|
||||
@ -40,9 +47,12 @@ export default function Verifications({ verifications, status }) {
|
||||
processed_at: '2026-05-23 14:15',
|
||||
status: 'approved',
|
||||
confidence: 98,
|
||||
verification_method: 'Auto',
|
||||
document_type: 'Passport',
|
||||
verification_method: 'Auto-OCR',
|
||||
document_type: 'Passport & Visa Scan',
|
||||
warnings: [],
|
||||
document_images: [
|
||||
'https://images.unsplash.com/photo-1544717305-2782549b5136?q=80&w=600&auto=format&fit=crop',
|
||||
],
|
||||
ocr_data: {
|
||||
'Name': 'Fatima Zahra',
|
||||
'DOB': '1992-08-14',
|
||||
@ -59,9 +69,12 @@ export default function Verifications({ verifications, status }) {
|
||||
processed_at: '2026-05-23 11:30',
|
||||
status: 'flagged',
|
||||
confidence: 58,
|
||||
verification_method: 'Auto',
|
||||
document_type: 'Passport',
|
||||
verification_method: 'Auto-OCR',
|
||||
document_type: 'Passport Scan',
|
||||
warnings: ['Blurry text region', 'Signature discrepancy flagged'],
|
||||
document_images: [
|
||||
'https://images.unsplash.com/photo-1580489944761-15a19d654956?q=80&w=600&auto=format&fit=crop'
|
||||
],
|
||||
ocr_data: {
|
||||
'Name': 'Amina Diop',
|
||||
'DOB': '1994-12-01',
|
||||
@ -78,9 +91,12 @@ export default function Verifications({ verifications, status }) {
|
||||
processed_at: '2026-05-22 16:40',
|
||||
status: 'flagged',
|
||||
confidence: 62,
|
||||
verification_method: 'Auto',
|
||||
verification_method: 'Auto-OCR',
|
||||
document_type: 'Visa scan details',
|
||||
warnings: ['Document expiration alert (Expires in 5 days)'],
|
||||
document_images: [
|
||||
'https://images.unsplash.com/photo-1534528741775-53994a69daeb?q=80&w=600&auto=format&fit=crop'
|
||||
],
|
||||
ocr_data: {
|
||||
'Name': 'Siti Aminah',
|
||||
'DOB': '1988-03-25',
|
||||
@ -119,24 +135,24 @@ export default function Verifications({ verifications, status }) {
|
||||
};
|
||||
|
||||
return (
|
||||
<AdminLayout title="Verifications">
|
||||
<Head title="Verifications" />
|
||||
<AdminLayout title="Vetting & OCR Review Hub">
|
||||
<Head title="OCR Verifications" />
|
||||
|
||||
<div className="font-sans max-w-7xl mx-auto space-y-6 pb-12">
|
||||
<div className="font-sans max-w-7xl mx-auto space-y-6">
|
||||
|
||||
{/* Simplified Header */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 border-b border-slate-200 pb-5">
|
||||
{/* Header overview and status */}
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-xl font-black text-slate-800 uppercase tracking-tight">Worker Verifications</h1>
|
||||
<p className="text-xs text-slate-500 mt-0.5 font-medium">Verify workers, check passports, and override data inputs.</p>
|
||||
<h1 className="text-xl font-bold text-gray-900 tracking-tight">OCR Vetting Queue & Verification System</h1>
|
||||
<p className="text-xs text-gray-500 mt-0.5">High-confidence auto-approvals, low-confidence warning audits, and inline OCR override triggers.</p>
|
||||
</div>
|
||||
|
||||
{/* Filter Tabs */}
|
||||
<div className="flex bg-slate-100 p-1 rounded-xl w-fit border border-slate-200">
|
||||
<div className="flex bg-slate-200/60 p-1 rounded-xl w-fit border border-slate-200 font-sans">
|
||||
{[
|
||||
{ label: 'All', value: 'all' },
|
||||
{ label: 'Approved', value: 'approved' },
|
||||
{ label: 'Flagged', value: 'rejected' },
|
||||
{ label: 'All Audits', value: 'all' },
|
||||
{ label: 'Auto-Approved (High Confidence)', value: 'approved' },
|
||||
{ label: 'Vetting Flags (Low Confidence)', value: 'rejected' },
|
||||
].map((tab) => (
|
||||
<button
|
||||
key={tab.value}
|
||||
@ -145,7 +161,7 @@ export default function Verifications({ verifications, status }) {
|
||||
className={`px-4 py-1.5 rounded-lg text-[10px] font-black uppercase tracking-wider transition-all ${
|
||||
(status || 'all') === tab.value
|
||||
? 'bg-white text-[#0F6E56] shadow-sm'
|
||||
: 'text-slate-650 hover:text-slate-900'
|
||||
: 'text-slate-600 hover:text-slate-900'
|
||||
}`}
|
||||
>
|
||||
{tab.label}
|
||||
@ -158,43 +174,43 @@ export default function Verifications({ verifications, status }) {
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
<div className="bg-white p-5 border border-slate-200 rounded-2xl flex items-center justify-between shadow-sm">
|
||||
<div>
|
||||
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest block">Auto-Approve Rate</span>
|
||||
<span className="text-2xl font-black text-slate-800 mt-1">92.4%</span>
|
||||
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest block">System Auto-Approve Rate</span>
|
||||
<span className="text-2xl font-black text-slate-800">92.4%</span>
|
||||
</div>
|
||||
<div className="w-10 h-10 bg-emerald-50 rounded-xl flex items-center justify-center text-emerald-600">
|
||||
<ShieldCheck className="w-5 h-5" />
|
||||
<div className="w-10 h-10 bg-emerald-50 rounded-xl flex items-center justify-center">
|
||||
<ShieldCheck className="w-5 h-5 text-emerald-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white p-5 border border-slate-200 rounded-2xl flex items-center justify-between shadow-sm">
|
||||
<div>
|
||||
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest block">Average Match Score</span>
|
||||
<span className="text-2xl font-black text-blue-600 mt-1">91.8%</span>
|
||||
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest block">OCR Average Confidence Score</span>
|
||||
<span className="text-2xl font-black text-blue-600">91.8%</span>
|
||||
</div>
|
||||
<div className="w-10 h-10 bg-blue-50 rounded-xl flex items-center justify-center text-blue-600">
|
||||
<Sparkles className="w-5 h-5" />
|
||||
<div className="w-10 h-10 bg-blue-50 rounded-xl flex items-center justify-center">
|
||||
<Sparkles className="w-5 h-5 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white p-5 border border-slate-200 rounded-2xl flex items-center justify-between shadow-sm">
|
||||
<div>
|
||||
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest block">Flagged Workers</span>
|
||||
<span className="text-2xl font-black text-amber-600 mt-1">2 Pending</span>
|
||||
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest block">Vetting Queue Flags</span>
|
||||
<span className="text-2xl font-black text-amber-600">2 Pending review</span>
|
||||
</div>
|
||||
<div className="w-10 h-10 bg-amber-50 rounded-xl flex items-center justify-center text-amber-600">
|
||||
<AlertTriangle className="w-5 h-5" />
|
||||
<div className="w-10 h-10 bg-amber-50 rounded-xl flex items-center justify-center">
|
||||
<AlertTriangle className="w-5 h-5 text-amber-600" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Verification Queue Datagrid */}
|
||||
<div className="bg-white rounded-2xl border border-slate-200 shadow-sm overflow-hidden">
|
||||
<div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-slate-50/70 border-b border-slate-150 text-[10px] font-black text-slate-500 uppercase tracking-widest">
|
||||
<tr className="bg-slate-50/70 border-b border-slate-100 text-[10px] font-black text-slate-500 uppercase tracking-widest">
|
||||
<th className="py-4 px-6">Worker Name</th>
|
||||
<th className="py-4 px-6">Passport ID</th>
|
||||
<th className="py-4 px-6">Confidence Score</th>
|
||||
<th className="py-4 px-6">Warnings</th>
|
||||
<th className="py-4 px-6">OCR Confidence Score</th>
|
||||
<th className="py-4 px-6">Vetting Warnings</th>
|
||||
<th className="py-4 px-6">Processed Date</th>
|
||||
<th className="py-4 px-6 text-right">Action</th>
|
||||
</tr>
|
||||
@ -205,13 +221,13 @@ export default function Verifications({ verifications, status }) {
|
||||
<td className="py-4 px-6">
|
||||
<div>
|
||||
<div className="text-sm font-black text-slate-900">{item.worker_name}</div>
|
||||
<div className="text-[10px] text-slate-400 font-semibold mt-0.5">{item.nationality} • {item.document_type}</div>
|
||||
<div className="text-[10px] text-slate-400 font-semibold">{item.nationality} • {item.document_type}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-4 px-6 font-mono font-semibold text-slate-650">{item.passport_number}</td>
|
||||
<td className="py-4 px-6 font-mono font-medium">{item.passport_number}</td>
|
||||
<td className="py-4 px-6">
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="w-24 bg-slate-100 rounded-full h-2 overflow-hidden">
|
||||
<div className="w-24 bg-slate-100 rounded-full h-2.5 overflow-hidden">
|
||||
<div
|
||||
className={`h-full rounded-full ${
|
||||
item.confidence > 90 ? 'bg-emerald-500' :
|
||||
@ -228,15 +244,15 @@ export default function Verifications({ verifications, status }) {
|
||||
</td>
|
||||
<td className="py-4 px-6">
|
||||
{item.warnings.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
<div className="space-y-1">
|
||||
{item.warnings.map((w, idx) => (
|
||||
<span key={idx} className="inline-flex items-center bg-red-50 text-red-700 text-[9px] px-2.5 py-0.5 rounded-full uppercase font-black tracking-wide border border-red-100">
|
||||
{w}
|
||||
<span key={idx} className="inline-flex items-center bg-red-50 text-red-700 text-[9px] px-2 py-0.5 rounded-full uppercase">
|
||||
<AlertTriangle className="w-2.5 h-2.5 mr-1" /> {w}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-[10px] font-black text-emerald-600 uppercase">None</span>
|
||||
<span className="text-[10px] font-black text-emerald-600 uppercase">None (Safe)</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="py-4 px-6 text-slate-400 font-semibold">{item.processed_at}</td>
|
||||
@ -246,11 +262,11 @@ export default function Verifications({ verifications, status }) {
|
||||
onClick={() => openReview(item)}
|
||||
className={`inline-flex items-center px-4 py-2 border rounded-xl text-[10px] font-black uppercase tracking-wider transition-colors focus:outline-none ${
|
||||
item.status === 'flagged'
|
||||
? 'bg-amber-500 text-white border-none shadow-md hover:bg-amber-600'
|
||||
? 'bg-amber-500 text-white border-none shadow-md shadow-amber-500/20 hover:bg-amber-600'
|
||||
: 'bg-white hover:bg-slate-50 text-slate-700 border-slate-200'
|
||||
}`}
|
||||
>
|
||||
{item.status === 'flagged' ? 'Verify' : 'Logs'}
|
||||
{item.status === 'flagged' ? 'Verify OCR' : 'Audit Log'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
@ -261,68 +277,117 @@ export default function Verifications({ verifications, status }) {
|
||||
</div>
|
||||
|
||||
{/* Audit logs details grid */}
|
||||
<div className="bg-white rounded-2xl p-6 border border-slate-200 shadow-sm space-y-4">
|
||||
<h3 className="text-xs font-black text-slate-800 uppercase tracking-widest flex items-center gap-1.5 border-b border-slate-100 pb-3">
|
||||
<div className="bg-slate-50 rounded-2xl p-5 border border-slate-200">
|
||||
<h3 className="text-xs font-black text-slate-800 uppercase tracking-widest flex items-center gap-1.5 mb-3">
|
||||
<History className="w-4 h-4 text-slate-600" />
|
||||
<span>Verification History</span>
|
||||
<span>OCR Vetting Action Audit Logs</span>
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
{[
|
||||
{ time: '2026-05-23 15:20', text: 'Admin manual approval completed for Leila Bekri passport verification', ip: '192.168.1.5' },
|
||||
{ time: '2026-05-23 14:15', text: 'Auto passport verification completed successfully for Fatima Zahra', ip: 'Auto-System' },
|
||||
{ time: '2026-05-23 13:42', text: 'Auto verification flagged passport scan of Grace Omondi due to blurred signature scan', ip: 'Auto-System' },
|
||||
{ time: '2026-05-23 15:20', text: 'Admin Vetting Officer manually approved passport scan verification for Leila Bekri', ip: '192.168.1.5' },
|
||||
{ time: '2026-05-23 14:15', text: 'System OCR Passport auto-verification completed successfully for Fatima Zahra', ip: 'Auto-System' },
|
||||
{ time: '2026-05-23 13:42', text: 'System flagged passport scan of Grace Omondi due to blurred signature signature check failure', ip: 'Auto-System' },
|
||||
].map((log, idx) => (
|
||||
<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 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 className="flex items-center space-x-3">
|
||||
<Clock className="w-4 h-4 text-slate-400" />
|
||||
<span>{log.text}</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-slate-400 font-semibold">{log.time} • IP: {log.ip}</span>
|
||||
<span className="text-[10px] text-slate-400">{log.time} • IP: {log.ip}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Verification Audit Dialog - Simplified & Width Optimized */}
|
||||
{/* Vetting Vetting Audit Dialog */}
|
||||
<Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
|
||||
<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">
|
||||
<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">
|
||||
<DialogHeader className="p-6 border-b border-slate-100 bg-[#0f6e56]/5 flex-shrink-0">
|
||||
<DialogTitle className="text-lg font-black text-slate-800 flex items-center justify-between tracking-tight">
|
||||
<span className="flex items-center gap-2">
|
||||
<ShieldCheck className="w-5 h-5 text-[#0F6E56]" />
|
||||
<span>Verify Worker: <span className="text-[#0F6E56]">{selectedItem?.worker_name}</span></span>
|
||||
<span>Vetting Manual Audit: <span className="text-[#0F6E56]">{selectedItem?.worker_name}</span></span>
|
||||
</span>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Badge className="border-none bg-[#0F6E56] text-white px-3.5 py-1.5 font-black text-[9px] uppercase tracking-wider rounded-lg shadow-sm">
|
||||
<span>Score: {selectedItem?.confidence}%</span>
|
||||
<Badge className={`border-none px-3.5 py-1.5 font-black text-[9px] uppercase tracking-wider rounded-lg shadow-sm flex items-center gap-1 ${
|
||||
selectedItem?.confidence > 80
|
||||
? 'bg-emerald-500 text-white shadow-emerald-500/20'
|
||||
: 'bg-amber-500 text-white shadow-amber-500/20'
|
||||
}`}>
|
||||
<Sparkles className="w-3.5 h-3.5 animate-pulse" />
|
||||
<span>OCR Confidence: {selectedItem?.confidence}%</span>
|
||||
</Badge>
|
||||
</div>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="p-6 overflow-y-auto flex-1 bg-slate-50/30 space-y-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 p-6 overflow-y-auto flex-1 bg-slate-50/30">
|
||||
|
||||
{/* Warnings Box - Rendered Inline */}
|
||||
{selectedItem?.warnings?.length > 0 && (
|
||||
<div className="bg-rose-50/50 border border-rose-100 p-5 rounded-2xl space-y-3 shadow-sm">
|
||||
<span className="text-[10px] font-black text-rose-700 uppercase tracking-widest flex items-center gap-1.5">
|
||||
<AlertTriangle className="w-4 h-4 text-rose-600 animate-bounce" />
|
||||
<span>Verification Warnings</span>
|
||||
</span>
|
||||
<ul className="space-y-2 text-xs font-bold text-rose-950">
|
||||
{selectedItem.warnings.map((w, idx) => (
|
||||
<li key={idx} className="flex items-center gap-2 bg-white px-3 py-2 rounded-xl border border-rose-100 shadow-xs">
|
||||
<div className="w-1.5 h-1.5 bg-rose-500 rounded-full flex-shrink-0" />
|
||||
<span>{w}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{/* Scanned proof & Warnings Column */}
|
||||
<div className="space-y-5">
|
||||
<div className="bg-white p-4 rounded-2xl border border-slate-200 shadow-sm space-y-3">
|
||||
<h3 className="text-xs font-black text-slate-700 uppercase tracking-widest flex items-center gap-1.5">
|
||||
<FileText className="w-4 h-4 text-[#0F6E56]" />
|
||||
<span>Scanned Passport Scan Document</span>
|
||||
</h3>
|
||||
{selectedItem?.document_type?.toLowerCase().includes('emirates') || selectedItem?.document_type?.toLowerCase().includes('eid') || selectedItem?.document_type?.toLowerCase().includes('id') ? (
|
||||
<div className="relative rounded-xl overflow-hidden border border-slate-200 bg-slate-950 flex flex-col items-center justify-center p-6 text-center aspect-[4/3] max-h-72 shadow-inner">
|
||||
<ShieldCheck className="w-12 h-12 text-emerald-500 mb-3 animate-pulse" />
|
||||
<div className="text-xs font-black text-white uppercase tracking-wider">Raw Scan Deleted (PDPL Law)</div>
|
||||
<div className="text-[10px] text-slate-400 mt-2 font-medium leading-relaxed">
|
||||
To comply strictly with UAE Personal Data Protection Law (PDPL), raw Emirates ID images are immediately destroyed post-OCR. Vetted metadata has been securely preserved.
|
||||
</div>
|
||||
<div className="mt-4 bg-emerald-600 text-white font-black text-[8px] tracking-wider uppercase border-none px-2.5 py-1 rounded">
|
||||
[DELETED_FOR_PDPL_COMPLIANCE]
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
onClick={() => setLightboxImage(selectedItem?.document_images[0])}
|
||||
className="relative rounded-xl overflow-hidden border border-slate-200 bg-slate-100 cursor-zoom-in shadow-inner aspect-[4/3] max-h-72 group"
|
||||
>
|
||||
<img
|
||||
src={selectedItem?.document_images[0]}
|
||||
alt="Verification Scan"
|
||||
className="w-full h-full object-cover group-hover:scale-102 transition-transform duration-300"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center text-white text-xs font-black uppercase tracking-wider backdrop-blur-[1px]">
|
||||
<ZoomIn className="w-4.5 h-4.5 mr-1" /> Click to Zoom Document
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[10px] text-slate-400 font-bold text-center uppercase tracking-wide">
|
||||
Type: {selectedItem?.document_type}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* OCR Data Extraction Form - Styled Cleanly */}
|
||||
<div className="bg-white p-5 border border-slate-200 rounded-2xl shadow-sm space-y-6">
|
||||
{/* Warnings Box */}
|
||||
{selectedItem?.warnings?.length > 0 ? (
|
||||
<div className="bg-rose-50/50 border border-rose-100 p-5 rounded-2xl space-y-3 shadow-sm">
|
||||
<span className="text-[10px] font-black text-rose-700 uppercase tracking-widest flex items-center gap-1.5">
|
||||
<AlertTriangle className="w-4 h-4 text-rose-600 animate-bounce" />
|
||||
<span>Suspicious Document Vetting Warning</span>
|
||||
</span>
|
||||
<ul className="space-y-2 text-xs font-bold text-rose-950">
|
||||
{selectedItem.warnings.map((w, idx) => (
|
||||
<li key={idx} className="flex items-center gap-2 bg-white px-3 py-2 rounded-xl border border-rose-100 shadow-xs">
|
||||
<div className="w-1.5 h-1.5 bg-rose-500 rounded-full flex-shrink-0" />
|
||||
<span>{w}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-emerald-50/30 border border-emerald-100 p-4 rounded-2xl flex items-center space-x-2 text-emerald-800 text-xs font-bold">
|
||||
<CheckCircle className="w-4 h-4 text-emerald-600" />
|
||||
<span>Vetting passed checks. System matches signature algorithms perfectly.</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* OCR Data Extraction Form Column - Supports editing */}
|
||||
<div className="bg-white p-5 border border-slate-200 rounded-2xl shadow-sm space-y-6 flex flex-col justify-between">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between border-b border-slate-100 pb-2">
|
||||
<h3 className="text-xs font-black text-slate-700 uppercase tracking-widest">Extracted Text Vitals</h3>
|
||||
@ -331,18 +396,18 @@ export default function Verifications({ verifications, status }) {
|
||||
className="text-xs text-[#0F6E56] hover:text-[#085041] font-black flex items-center gap-1 uppercase tracking-wider text-[10px]"
|
||||
>
|
||||
<Edit3 className="w-3.5 h-3.5" />
|
||||
<span>{isEditingOcr ? 'Cancel' : 'Edit values'}</span>
|
||||
<span>{isEditingOcr ? 'Cancel Edit' : 'Edit OCR Values'}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<div className="space-y-4">
|
||||
{Object.entries(ocrOverrides).map(([key, value]) => (
|
||||
<div key={key} className="space-y-1.5">
|
||||
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest block">{key}</label>
|
||||
{isEditingOcr ? (
|
||||
<input
|
||||
type="text"
|
||||
className="w-full bg-slate-50 border border-slate-200 rounded-xl px-3.5 py-2.5 text-xs font-bold focus:bg-white outline-none focus:ring-2 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-4 focus:ring-[#0F6E56]/10 focus:border-[#0F6E56] transition-all"
|
||||
value={value}
|
||||
onChange={e => {
|
||||
const newVal = e.target.value;
|
||||
@ -362,11 +427,11 @@ export default function Verifications({ verifications, status }) {
|
||||
{/* Manual Rejection reason input */}
|
||||
{showRejectionForm ? (
|
||||
<div className="bg-rose-50/30 p-4 border border-rose-100 rounded-xl space-y-3">
|
||||
<label className="text-[10px] font-black text-rose-700 uppercase tracking-widest block">Reason for Rejection</label>
|
||||
<label className="text-[10px] font-black text-rose-700 uppercase tracking-widest block">Rejection compliance reason</label>
|
||||
<textarea
|
||||
rows="2"
|
||||
className="w-full bg-white border border-rose-200 rounded-xl p-3 text-xs font-bold focus:ring-4 focus:ring-rose-500/10 outline-none transition-all"
|
||||
placeholder="Explain verification error (e.g. invalid document)..."
|
||||
placeholder="Explain to worker what document error occurred (e.g., expiry date mismatch, blurred image)..."
|
||||
value={rejectionReason}
|
||||
onChange={e => setRejectionReason(e.target.value)}
|
||||
/>
|
||||
@ -375,11 +440,11 @@ export default function Verifications({ verifications, status }) {
|
||||
onClick={() => handleVerifySubmit('reject')}
|
||||
className="px-4 py-2.5 bg-red-600 hover:bg-red-700 text-white rounded-xl text-[10px] font-black uppercase tracking-widest shadow-md transition-colors"
|
||||
>
|
||||
Confirm
|
||||
Confirm Rejection
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowRejectionForm(false)}
|
||||
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"
|
||||
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"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
@ -389,17 +454,17 @@ export default function Verifications({ verifications, status }) {
|
||||
<div className="flex gap-3 pt-4 border-t border-slate-100 flex-shrink-0">
|
||||
<button
|
||||
onClick={() => handleVerifySubmit('approve')}
|
||||
className="flex-1 py-3 bg-[#0F6E56] text-white rounded-xl text-[10px] font-black uppercase tracking-widest shadow-lg 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 shadow-teal-700/20 hover:bg-[#085041] transition-all flex items-center justify-center gap-1"
|
||||
>
|
||||
<CheckCircle className="w-4 h-4" />
|
||||
<span>Approve</span>
|
||||
<span>Approve Verification</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowRejectionForm(true)}
|
||||
className="flex-1 py-3 bg-red-600 text-white rounded-xl text-[10px] font-black uppercase tracking-widest shadow-lg 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 shadow-red-600/10 hover:bg-red-700 transition-all flex items-center justify-center gap-1"
|
||||
>
|
||||
<XCircle className="w-4 h-4" />
|
||||
<span>Reject</span>
|
||||
<span>Flag Rejection</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
@ -407,6 +472,26 @@ export default function Verifications({ verifications, status }) {
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Lightbox Zoom */}
|
||||
{lightboxImage && (
|
||||
<div
|
||||
onClick={() => setLightboxImage(null)}
|
||||
className="fixed inset-0 z-50 bg-black/80 backdrop-blur-sm flex items-center justify-center p-4"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute top-6 right-6 text-white/80 hover:text-white p-2 bg-white/10 rounded-full transition-all focus:outline-none"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
<img
|
||||
src={lightboxImage}
|
||||
alt="Zoomed scan"
|
||||
className="max-w-full max-h-[90vh] object-contain rounded-xl shadow-2xl border border-white/20"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</AdminLayout>
|
||||
);
|
||||
}
|
||||
|
||||
@ -77,8 +77,6 @@ export default function Show({ conversation, initialMessages, conversations = []
|
||||
setMessages(prev => [...prev, newMsg]);
|
||||
setInput('');
|
||||
|
||||
const isLookingJobQuery = text.toLowerCase().includes('looking') && text.toLowerCase().includes('job');
|
||||
|
||||
router.post(`/employer/messages/${conversation.id}/send`, {
|
||||
text: text
|
||||
}, {
|
||||
@ -95,23 +93,18 @@ export default function Show({ conversation, initialMessages, conversations = []
|
||||
setIsTyping(true);
|
||||
setTimeout(() => {
|
||||
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]);
|
||||
|
||||
// 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), {
|
||||
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
|
||||
});
|
||||
}
|
||||
}
|
||||
// Pop up a clear message received notification for the sponsor
|
||||
toast.info(t('new_message_from', 'New message from {name}!').replace('{name}', conversation.worker_name), {
|
||||
description: autoReply.text,
|
||||
duration: 5000
|
||||
});
|
||||
}, 2000);
|
||||
}, 1500);
|
||||
@ -143,7 +136,6 @@ export default function Show({ conversation, initialMessages, conversations = []
|
||||
};
|
||||
|
||||
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_salary', "What is your final expected salary?"),
|
||||
t('chip_visa', "Can you transfer your visa immediately?")
|
||||
|
||||
@ -1,671 +0,0 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Head } from '@inertiajs/react';
|
||||
import EmployerLayout from '../../Layouts/EmployerLayout';
|
||||
import { useTranslation } from '../../lib/LanguageContext';
|
||||
import {
|
||||
CreditCard,
|
||||
CheckCircle2,
|
||||
Search,
|
||||
FileText,
|
||||
Download,
|
||||
Printer,
|
||||
Copy,
|
||||
FileSpreadsheet,
|
||||
ArrowUpRight,
|
||||
TrendingUp,
|
||||
Filter,
|
||||
Calendar,
|
||||
ChevronRight,
|
||||
DollarSign,
|
||||
RefreshCw
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export default function PaymentHistory({ payments = [], currentPlan, expiresAt, subscriptionStatus }) {
|
||||
const { t } = useTranslation();
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const [sortBy, setSortBy] = useState('date_desc');
|
||||
|
||||
// Calculate dynamic stats
|
||||
const totalSpent = payments.reduce((acc, curr) => acc + (curr.status === 'success' ? curr.amount : 0), 0);
|
||||
const successfulCount = payments.filter(p => p.status === 'success').length;
|
||||
const failedCount = payments.filter(p => p.status === 'failed').length;
|
||||
|
||||
// Filter and Sort Logic
|
||||
const filteredPayments = payments
|
||||
.filter(payment => {
|
||||
const matchesSearch =
|
||||
payment.description.toLowerCase().includes(searchTerm.toLowerCase()) ||
|
||||
payment.invoice_no.toLowerCase().includes(searchTerm.toLowerCase());
|
||||
|
||||
const matchesStatus = statusFilter === 'all' || payment.status === statusFilter;
|
||||
|
||||
return matchesSearch && matchesStatus;
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (sortBy === 'date_desc') {
|
||||
return new Date(b.date) - new Date(a.date);
|
||||
}
|
||||
if (sortBy === 'date_asc') {
|
||||
return new Date(a.date) - new Date(b.date);
|
||||
}
|
||||
if (sortBy === 'amount_desc') {
|
||||
return b.amount - a.amount;
|
||||
}
|
||||
if (sortBy === 'amount_asc') {
|
||||
return a.amount - b.amount;
|
||||
}
|
||||
return 0;
|
||||
});
|
||||
|
||||
const downloadReceipt = (invoice) => {
|
||||
// Calculate UAE VAT (5%)
|
||||
const total = parseFloat(invoice.amount);
|
||||
const subtotal = total / 1.05;
|
||||
const vat = total - subtotal;
|
||||
|
||||
// Create a new window for print rendering
|
||||
const printWindow = window.open('', '_blank', 'width=900,height=900');
|
||||
if (!printWindow) {
|
||||
toast.error(t('popup_blocked', 'Popup blocked! Please allow popups to download receipts.'));
|
||||
return;
|
||||
}
|
||||
|
||||
printWindow.document.write(`
|
||||
<html>
|
||||
<head>
|
||||
<title>Invoice - ${invoice.invoice_no}</title>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
body {
|
||||
font-family: 'Inter', sans-serif;
|
||||
color: #1e293b;
|
||||
background-color: #ffffff;
|
||||
padding: 50px;
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
font-size: 13px;
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
.invoice-container {
|
||||
max-width: 850px;
|
||||
margin: 0 auto;
|
||||
background: #ffffff;
|
||||
}
|
||||
.header-banner {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 50px;
|
||||
}
|
||||
.logo-placeholder {
|
||||
font-size: 24px;
|
||||
font-weight: 950;
|
||||
color: #185FA5;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
.header-banner h1 {
|
||||
font-size: 38px;
|
||||
font-weight: 800;
|
||||
color: #185FA5;
|
||||
margin: 0;
|
||||
letter-spacing: -1px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.address-grid {
|
||||
display: grid;
|
||||
grid-template-cols: 1fr 1fr 1.2fr;
|
||||
gap: 30px;
|
||||
margin-bottom: 50px;
|
||||
}
|
||||
.address-block h3 {
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
color: #185FA5;
|
||||
margin: 0 0 10px 0;
|
||||
border-bottom: 1.5px solid #e2e8f0;
|
||||
padding-bottom: 6px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
.address-block p {
|
||||
margin: 0 0 5px 0;
|
||||
color: #475569;
|
||||
font-weight: 500;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.address-block p strong {
|
||||
color: #0f172a;
|
||||
font-weight: 700;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin-bottom: 40px;
|
||||
}
|
||||
th {
|
||||
background-color: #185FA5;
|
||||
color: #ffffff;
|
||||
padding: 12px 16px;
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
border: 1px solid #185FA5;
|
||||
}
|
||||
th:first-child {
|
||||
border-top-left-radius: 4px;
|
||||
}
|
||||
th:last-child {
|
||||
border-top-right-radius: 4px;
|
||||
}
|
||||
td {
|
||||
padding: 16px;
|
||||
color: #334155;
|
||||
font-weight: 500;
|
||||
border: 1px solid #e2e8f0;
|
||||
background-color: #fafafa;
|
||||
}
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
.text-right {
|
||||
text-align: right;
|
||||
}
|
||||
.totals-section {
|
||||
width: 320px;
|
||||
margin-left: auto;
|
||||
margin-bottom: 50px;
|
||||
}
|
||||
.totals-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 8px 0;
|
||||
color: #475569;
|
||||
font-weight: 600;
|
||||
}
|
||||
.totals-row.grand-total {
|
||||
border-top: 2px solid #185FA5;
|
||||
font-size: 16px;
|
||||
font-weight: 900;
|
||||
color: #0f172a;
|
||||
padding-top: 10px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.details-section {
|
||||
margin-bottom: 50px;
|
||||
}
|
||||
.details-title {
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
color: #185FA5;
|
||||
margin: 0 0 10px 0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
border-bottom: 1.5px solid #e2e8f0;
|
||||
padding-bottom: 6px;
|
||||
}
|
||||
.details-content {
|
||||
color: #475569;
|
||||
font-weight: 500;
|
||||
margin-bottom: 24px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.signature-block {
|
||||
display: grid;
|
||||
grid-template-cols: 1fr 1fr;
|
||||
gap: 80px;
|
||||
margin-top: 80px;
|
||||
margin-bottom: 60px;
|
||||
}
|
||||
.sig-line {
|
||||
border-top: 1.5px solid #94a3b8;
|
||||
text-align: center;
|
||||
padding-top: 8px;
|
||||
font-size: 10px;
|
||||
font-weight: 800;
|
||||
color: #475569;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
.footer-bar {
|
||||
background-color: #185FA5;
|
||||
color: #ffffff;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.footer-bar span {
|
||||
opacity: 0.9;
|
||||
}
|
||||
.btn-print {
|
||||
position: fixed;
|
||||
bottom: 30px;
|
||||
right: 30px;
|
||||
background: #185FA5;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
border-radius: 12px;
|
||||
font-weight: 700;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 12px rgba(24, 95, 165, 0.3);
|
||||
transition: all 0.2s;
|
||||
z-index: 999;
|
||||
}
|
||||
.btn-print:hover {
|
||||
background: #144f8a;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
@media print {
|
||||
body {
|
||||
padding: 0;
|
||||
background: none;
|
||||
}
|
||||
.invoice-container {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
}
|
||||
.btn-print {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="invoice-container">
|
||||
<div class="header-banner">
|
||||
<div class="logo-placeholder">MARKETPLACE</div>
|
||||
<h1>Invoice</h1>
|
||||
</div>
|
||||
|
||||
<div class="address-grid">
|
||||
<div class="address-block">
|
||||
<h3>Invoice From</h3>
|
||||
<p><strong>MOHRE Domestic Labor Portal</strong></p>
|
||||
<p>Domestic Labor Marketplace</p>
|
||||
<p>billing@marketplace.ae</p>
|
||||
<p>Abu Dhabi, UAE</p>
|
||||
<p>Phone: 800-MOHRE</p>
|
||||
<p>TRN: 100293810200003</p>
|
||||
</div>
|
||||
<div class="address-block">
|
||||
<h3>Invoice To</h3>
|
||||
<p><strong>Employer Account</strong></p>
|
||||
<p>Domestic Sponsor Household</p>
|
||||
<p>sponsor@marketplace.ae</p>
|
||||
<p>Dubai, UAE</p>
|
||||
<p>Phone: +971 50 123 4567</p>
|
||||
</div>
|
||||
<div class="address-block">
|
||||
<h3>Invoice Details</h3>
|
||||
<p><strong>Invoice Number:</strong> ${invoice.invoice_no}</p>
|
||||
<p><strong>Invoice Date:</strong> ${invoice.date}</p>
|
||||
<p><strong>Invoice Due Date:</strong> ${invoice.date}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 80px;">Items</th>
|
||||
<th>Description</th>
|
||||
<th class="text-center" style="width: 80px;">QTY</th>
|
||||
<th class="text-right" style="width: 120px;">Price</th>
|
||||
<th class="text-right" style="width: 120px;">Sales Tax</th>
|
||||
<th class="text-right" style="width: 120px;">Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td class="text-center">1</td>
|
||||
<td>${invoice.description}</td>
|
||||
<td class="text-center">1</td>
|
||||
<td class="text-right">${subtotal.toFixed(2)} AED</td>
|
||||
<td class="text-right">5% VAT</td>
|
||||
<td class="text-right">${total.toFixed(2)} AED</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div class="totals-section">
|
||||
<div class="totals-row">
|
||||
<span>Subtotal</span>
|
||||
<span>${subtotal.toFixed(2)} AED</span>
|
||||
</div>
|
||||
<div class="totals-row">
|
||||
<span>Sales Tax (5% VAT)</span>
|
||||
<span>${vat.toFixed(2)} AED</span>
|
||||
</div>
|
||||
<div class="totals-row grand-total">
|
||||
<span>Total</span>
|
||||
<span>${total.toFixed(2)} AED</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="details-section">
|
||||
<div class="details-title">Payment Details</div>
|
||||
<div class="details-content">
|
||||
<strong>Name:</strong> Domestic Labor Marketplace Portal<br>
|
||||
<strong>Account Number:</strong> 1029384756<br>
|
||||
<strong>Bank Name and Address:</strong> Emirates NBD Bank PJSC, Main Branch, Dubai<br>
|
||||
<strong>Bank Swift Code:</strong> EBILAEADXXX<br>
|
||||
<strong>IBAN Number:</strong> AE89 0030 0000 0010 2938 4756
|
||||
</div>
|
||||
|
||||
<div class="details-title">Payment Terms</div>
|
||||
<div class="details-content">
|
||||
Payment processed successfully via PayTabs Secured Checkout. Plan access updated instantly.
|
||||
</div>
|
||||
|
||||
<div class="details-title">Notes</div>
|
||||
<div class="details-content">
|
||||
Auto-renewal is enabled. Thank you for sponsoring domestic labor ethically through verified TADBEER channels.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="signature-block">
|
||||
<div class="sig-line">Signature</div>
|
||||
<div class="sig-line">Date</div>
|
||||
</div>
|
||||
|
||||
<div class="footer-bar">
|
||||
<span>This invoice was auto generated by Domestic Labor Marketplace.</span>
|
||||
<span>Powered by PayTabs Secured Gateway</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn-print" onclick="window.print()">Print / Save PDF</button>
|
||||
|
||||
<script>
|
||||
window.onload = function() {
|
||||
setTimeout(function() {
|
||||
window.print();
|
||||
}, 500);
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
printWindow.document.close();
|
||||
|
||||
toast.success(`📄 \${t('invoice_download_success', 'Receipt downloaded successfully')}`, {
|
||||
description: `\${invoice.invoice_no} has been converted to an official tax invoice.`,
|
||||
duration: 4000
|
||||
});
|
||||
};
|
||||
|
||||
const handlePrint = () => {
|
||||
window.print();
|
||||
};
|
||||
|
||||
const handleExportCSV = () => {
|
||||
toast.success(`📊 ${t('csv_exported', 'CSV Export Complete!')}`, {
|
||||
description: `${filteredPayments.length} billing items formatted and saved as spreadsheet.`
|
||||
});
|
||||
};
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(JSON.stringify(filteredPayments, null, 2));
|
||||
toast.success(`📋 ${t('copied_to_clipboard', 'Copied to Clipboard!')}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<EmployerLayout title={t('payment_history', 'Payment History')}>
|
||||
<Head title={`${t('payment_history', 'Payment History')} - ${t('employer_portal', 'Employer Portal')}`} />
|
||||
|
||||
<div className="space-y-8 select-none pb-16">
|
||||
|
||||
{/* Header Title Section */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-black text-slate-900 tracking-tight">{t('payment_history', 'Payment History')}</h1>
|
||||
<p className="text-xs font-medium text-slate-500 mt-1">
|
||||
{t('payment_history_desc', 'Track subscription plans, document vetting, and direct hiring invoice details.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dashboard Stats Panel */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||
{/* Card 1: Current Active Plan */}
|
||||
<div className="bg-white p-6 rounded-3xl border border-slate-200 shadow-xs relative overflow-hidden group hover:scale-[1.01] transition-all duration-300">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-black uppercase tracking-widest text-slate-400">{t('current_active_plan', 'Current Plan')}</span>
|
||||
<span className="p-1.5 bg-blue-50 text-blue-600 rounded-xl border border-blue-100">
|
||||
<CreditCard className="w-4 h-4" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<h2 className="text-base font-black tracking-tight text-slate-900 truncate" title={currentPlan}>{currentPlan}</h2>
|
||||
</div>
|
||||
<p className="text-[10px] text-emerald-600 font-bold mt-2 flex items-center">
|
||||
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500 mr-1.5 animate-pulse" />
|
||||
{subscriptionStatus || 'Active'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Card 2: Next Renewal Date */}
|
||||
<div className="bg-white p-6 rounded-3xl border border-slate-200 shadow-xs relative overflow-hidden group hover:scale-[1.01] transition-all duration-300">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-black uppercase tracking-widest text-slate-400">{t('next_renewal_date', 'Next Renewal')}</span>
|
||||
<span className="p-1.5 bg-amber-50 text-amber-600 rounded-xl border border-amber-100">
|
||||
<Calendar className="w-4 h-4" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<h2 className="text-base font-black tracking-tight text-slate-900">{expiresAt}</h2>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 font-medium mt-2">
|
||||
{t('auto_renew_enabled', 'Auto-renewal is enabled')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Card 3: Total Spent */}
|
||||
<div className="bg-gradient-to-br from-[#185FA5] to-blue-800 p-6 rounded-3xl text-white shadow-md relative overflow-hidden group hover:scale-[1.01] transition-all duration-300">
|
||||
<div className="absolute right-[-20px] bottom-[-20px] opacity-10 group-hover:scale-110 transition-transform duration-500">
|
||||
<TrendingUp className="w-40 h-40" />
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-black uppercase tracking-widest text-blue-200">{t('total_amount_invested', 'Total Spent')}</span>
|
||||
<span className="p-1.5 bg-white/10 rounded-xl text-blue-200">
|
||||
<DollarSign className="w-4 h-4" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 flex items-baseline space-x-1.5">
|
||||
<h2 className="text-2xl font-black tracking-tight">{totalSpent.toFixed(2)}</h2>
|
||||
<span className="text-xs font-bold text-blue-200">AED</span>
|
||||
</div>
|
||||
<p className="text-[10px] text-blue-200/80 font-medium mt-1">
|
||||
{t('across_all_periods', 'Across active subscription periods')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Card 4: Successful Orders */}
|
||||
<div className="bg-white p-6 rounded-3xl border border-slate-200 shadow-xs relative overflow-hidden group hover:scale-[1.01] transition-all duration-300">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-[10px] font-black uppercase tracking-widest text-slate-400">{t('authorized_invoices', 'Authorized Receipts')}</span>
|
||||
<span className="p-1.5 bg-emerald-50 text-emerald-600 rounded-xl border border-emerald-100">
|
||||
<CheckCircle2 className="w-4 h-4" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-4">
|
||||
<h2 className="text-base font-black tracking-tight text-slate-900">{successfulCount} Invoices</h2>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400 font-medium mt-2">
|
||||
{t('fully_vat_compliant', 'Fully UAE compliant & VAT logged')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table and Filter Section */}
|
||||
<div className="bg-white rounded-3xl border border-slate-200 shadow-sm overflow-hidden">
|
||||
|
||||
{/* Filter Controls Header */}
|
||||
<div className="p-6 border-b border-slate-100 bg-slate-50/50 flex flex-col md:flex-row md:items-center justify-between gap-4">
|
||||
|
||||
{/* Search and Filters */}
|
||||
<div className="flex flex-wrap items-center gap-3 flex-1">
|
||||
<div className="relative w-full sm:w-64">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder={t('search_by_description', 'Search by description or ID...')}
|
||||
className="w-full pl-10 pr-4 py-2 bg-white border border-slate-200 rounded-xl text-sm focus:ring-2 focus:ring-blue-100 focus:border-[#185FA5] transition-all outline-none"
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<select
|
||||
className="px-3 py-2 bg-white border border-slate-200 rounded-xl text-xs font-bold text-slate-600 outline-none focus:ring-2 focus:ring-blue-100 focus:border-[#185FA5] cursor-pointer"
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter(e.target.value)}
|
||||
>
|
||||
<option value="all">📁 {t('all_statuses', 'All Statuses')}</option>
|
||||
<option value="success">🟢 {t('status_success', 'Success')}</option>
|
||||
<option value="pending">🟡 {t('status_pending', 'Pending')}</option>
|
||||
<option value="failed">🔴 {t('status_failed', 'Failed')}</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
className="px-3 py-2 bg-white border border-slate-200 rounded-xl text-xs font-bold text-slate-600 outline-none focus:ring-2 focus:ring-blue-100 focus:border-[#185FA5] cursor-pointer"
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value)}
|
||||
>
|
||||
<option value="date_desc">📅 {t('date_newest', 'Date: Newest First')}</option>
|
||||
<option value="date_asc">📅 {t('date_oldest', 'Date: Oldest First')}</option>
|
||||
<option value="amount_desc">💰 {t('amount_highest', 'Amount: Highest First')}</option>
|
||||
<option value="amount_asc">💰 {t('amount_lowest', 'Amount: Lowest First')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Export Buttons */}
|
||||
<div className="flex items-center space-x-2.5">
|
||||
<div className="flex items-center bg-white border border-slate-200 rounded-xl p-1 shadow-xs">
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="px-3 py-1.5 text-[10px] font-black text-slate-500 hover:text-[#185FA5] hover:bg-blue-50 rounded-lg transition-all"
|
||||
>
|
||||
COPY
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExportCSV}
|
||||
className="px-3 py-1.5 text-[10px] font-black text-slate-500 hover:text-[#185FA5] hover:bg-blue-50 rounded-lg transition-all"
|
||||
>
|
||||
CSV
|
||||
</button>
|
||||
<button
|
||||
onClick={handlePrint}
|
||||
className="px-3 py-1.5 text-[10px] font-black text-slate-500 hover:text-[#185FA5] hover:bg-blue-50 rounded-lg transition-all"
|
||||
>
|
||||
PRINT
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table Data */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-100">
|
||||
<th className="px-6 py-4 text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('invoice_id', 'Invoice ID')}</th>
|
||||
<th className="px-6 py-4 text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('date_time', 'Date & Time')}</th>
|
||||
<th className="px-6 py-4 text-[10px] font-black text-slate-400 uppercase tracking-widest">{t('description', 'Description')}</th>
|
||||
<th className="px-6 py-4 text-[10px] font-black text-slate-400 uppercase tracking-widest text-center">{t('status', 'Status')}</th>
|
||||
<th className="px-6 py-4 text-[10px] font-black text-slate-400 uppercase tracking-widest text-right">{t('amount', 'Amount')}</th>
|
||||
<th className="px-6 py-4 text-[10px] font-black text-slate-400 uppercase tracking-widest text-right">{t('actions', 'Actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-50">
|
||||
{filteredPayments.length > 0 ? (
|
||||
filteredPayments.map((payment) => (
|
||||
<tr key={payment.id} className="group hover:bg-slate-50/50 transition-colors">
|
||||
<td className="px-6 py-5">
|
||||
<div className="flex items-center space-x-2 font-mono text-xs font-black text-[#185FA5]">
|
||||
<FileText className="w-3.5 h-3.5 text-slate-400" />
|
||||
<span>{payment.invoice_no}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-5">
|
||||
<div className="text-xs font-bold text-slate-700">{payment.date}</div>
|
||||
<div className="text-[10px] text-slate-400 font-medium mt-0.5">{payment.time}</div>
|
||||
</td>
|
||||
<td className="px-6 py-5">
|
||||
<div className="text-xs font-black text-slate-900">{payment.description}</div>
|
||||
<div className="text-[10px] text-slate-400 font-medium mt-0.5">PayTabs Secured Transfer</div>
|
||||
</td>
|
||||
<td className="px-6 py-5 text-center">
|
||||
<span className={`inline-flex items-center px-2.5 py-1 rounded-full text-[10px] font-black tracking-tight border ${
|
||||
payment.status === 'success'
|
||||
? 'bg-emerald-50 text-emerald-700 border-emerald-100'
|
||||
: (payment.status === 'pending' ? 'bg-amber-50 text-amber-700 border-amber-100' : 'bg-rose-50 text-rose-700 border-rose-100')
|
||||
}`}>
|
||||
<span className={`w-1 h-1 rounded-full mr-1.5 ${
|
||||
payment.status === 'success'
|
||||
? 'bg-emerald-500'
|
||||
: (payment.status === 'pending' ? 'bg-amber-500' : 'bg-rose-500')
|
||||
}`} />
|
||||
{payment.status.toUpperCase()}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-5 text-right">
|
||||
<span className="text-sm font-black text-slate-800">
|
||||
{payment.amount.toFixed(2)}{' '}
|
||||
<span className="text-[10px] font-bold text-slate-400">{payment.currency}</span>
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-5 text-right">
|
||||
<button
|
||||
onClick={() => downloadReceipt(payment)}
|
||||
className="p-2 bg-blue-50 text-[#185FA5] hover:bg-[#185FA5] hover:text-white rounded-lg transition-all"
|
||||
title={t('download_pdf_invoice', 'Download PDF Invoice')}
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan="6" className="p-20 text-center">
|
||||
<div className="space-y-3">
|
||||
<CreditCard className="w-12 h-12 text-slate-200 mx-auto" />
|
||||
<div className="text-base font-bold text-slate-400">
|
||||
{t('no_payments_logged', 'No transaction items logged')}
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 font-medium">
|
||||
{t('try_adjusting_filters', 'Try adjusting your search criteria or filter levels.')}
|
||||
</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Footer Summary Info */}
|
||||
<div className="px-6 py-4 bg-slate-50/50 border-t border-slate-100 flex items-center justify-between text-[10px] text-slate-500 font-bold uppercase tracking-wider">
|
||||
<div>
|
||||
{t('showing_invoices', 'Showing {start} to {end} of {total} billing items')
|
||||
.replace('{start}', filteredPayments.length > 0 ? 1 : 0)
|
||||
.replace('{end}', filteredPayments.length)
|
||||
.replace('{total}', filteredPayments.length)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</EmployerLayout>
|
||||
);
|
||||
}
|
||||
@ -9,7 +9,6 @@
|
||||
use App\Http\Controllers\Api\EmployerMessageController;
|
||||
use App\Http\Controllers\Api\EmployerAnnouncementController;
|
||||
use App\Http\Controllers\Api\EmployerProfileController;
|
||||
use App\Http\Controllers\Api\EmployerReviewController;
|
||||
|
||||
/*
|
||||
|--------------------------------------------------------------------------
|
||||
@ -52,8 +51,6 @@
|
||||
Route::post('/workers/go-live', [WorkerProfileController::class, 'goLive']);
|
||||
Route::post('/workers/profile/toggle-availability', [WorkerProfileController::class, 'toggleAvailability']);
|
||||
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
|
||||
Route::get('/workers/offers', [WorkerProfileController::class, 'getOffers']);
|
||||
@ -87,15 +84,7 @@
|
||||
|
||||
// Worker and Candidate Pipeline Management
|
||||
Route::get('/employers/workers', [\App\Http\Controllers\Api\EmployerWorkerController::class, 'getWorkers']);
|
||||
Route::get('/employers/workers/{id}', [\App\Http\Controllers\Api\EmployerWorkerController::class, 'getWorkerDetail']);
|
||||
Route::get('/employers/candidates', [\App\Http\Controllers\Api\EmployerWorkerController::class, 'getCandidates']);
|
||||
Route::post('/employers/candidates/{id}/hire', [\App\Http\Controllers\Api\EmployerWorkerController::class, 'hireCandidate']);
|
||||
Route::post('/employers/candidates/hire', [\App\Http\Controllers\Api\EmployerWorkerController::class, 'hireCandidate']);
|
||||
|
||||
// Payment History Management
|
||||
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']);
|
||||
});
|
||||
|
||||
@ -183,8 +183,6 @@
|
||||
|
||||
Route::get('/profile', [\App\Http\Controllers\Employer\ProfileController::class, 'index'])->name('employer.profile');
|
||||
Route::post('/profile/update', [\App\Http\Controllers\Employer\ProfileController::class, 'update'])->name('employer.profile.update');
|
||||
|
||||
Route::get('/payments', [\App\Http\Controllers\Employer\PaymentController::class, 'index'])->name('employer.payments');
|
||||
});
|
||||
|
||||
Route::get('/api/documentation', function () {
|
||||
|
||||
@ -19,7 +19,7 @@ export default defineConfig({
|
||||
tailwindcss(),
|
||||
],
|
||||
server: {
|
||||
host: '192.168.29.131',
|
||||
host: 'localhost',
|
||||
watch: {
|
||||
ignored: ['**/storage/framework/views/**'],
|
||||
},
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user