admin report module #6

Merged
mohanmd merged 1 commits from mohan into master 2026-06-15 10:14:08 +00:00
6 changed files with 607 additions and 301 deletions

View File

@ -360,66 +360,242 @@ public function refundPayment(Request $request, $id)
/** /**
* Interactive Reports Hub * Interactive Reports Hub
*/ */
public function analytics() /**
* Interactive Reports Hub
*/
public function analytics(Request $request)
{ {
// 1. Worker Stats $reportType = $request->input('report_type', 'workers');
$workerStats = [ $startDate = $request->input('start_date');
'total' => DB::table('workers')->count(), $endDate = $request->input('end_date');
'verified' => DB::table('workers')->where('verified', 1)->count(), $status = $request->input('status', 'all');
'active' => DB::table('workers')->where('status', 'active')->count(), $search = $request->input('search');
'inactive' => DB::table('workers')->where('status', 'inactive')->count(), $export = $request->input('export');
];
// 2. Sponsor Stats $headers = [];
$sponsorStats = [ $data = collect();
'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(),
];
// 3. Dispute & Safety Stats if ($reportType === 'workers') {
$disputeStats = [ $query = DB::table('workers');
'total' => DB::table('disputes')->count(), if ($startDate) {
'open' => DB::table('disputes')->where('status', 'Open')->count(), $query->whereDate('created_at', '>=', $startDate);
'under_review' => DB::table('disputes')->where('status', 'Under Review')->count(), }
'resolved' => DB::table('disputes')->where('status', 'Resolved')->count(), if ($endDate) {
]; $query->whereDate('created_at', '<=', $endDate);
}
if ($status && $status !== 'all') {
$query->where('status', $status);
}
if ($search) {
$query->where(function($q) use ($search) {
$q->where('name', 'like', "%{$search}%")
->orWhere('email', 'like', "%{$search}%")
->orWhere('nationality', 'like', "%{$search}%");
});
}
$safetyStats = [ $headers = ['Worker ID', 'Name', 'Email', 'Phone', 'Nationality', 'Status', 'Salary (AED)', 'Verified', 'Joined Date'];
'total' => DB::table('moderation_reports')->count(), $data = $query->orderBy('created_at', 'desc')->get()->map(function($w) {
'pending' => DB::table('moderation_reports')->where('status', 'Pending')->count(),
'resolved' => DB::table('moderation_reports')->where('status', 'Resolved')->count(),
];
// 4. Financial Payments & Ledger
$payments = DB::table('payments')
->leftJoin('users', 'payments.user_id', '=', 'users.id')
->select('payments.*', 'users.name as user_name', 'users.email as user_email')
->orderBy('payments.created_at', 'desc')
->get()
->map(function($payment) {
return [ return [
'id' => $payment->id, 'id' => 'WRK-' . str_pad($w->id, 4, '0', STR_PAD_LEFT),
'user_name' => $payment->user_name ?: 'System Guest / Sponsor', 'name' => $w->name,
'user_email' => $payment->user_email ?: 'no-email@marketplace.com', 'email' => $w->email,
'amount' => (float)$payment->amount, 'phone' => $w->phone ?: 'N/A',
'currency' => $payment->currency, 'nationality' => $w->nationality ?: 'N/A',
'description' => $payment->description, 'status' => $w->status,
'status' => $payment->status, 'salary' => (int)$w->salary,
'created_at' => date('M d, Y h:i A', strtotime($payment->created_at)), 'verified' => $w->verified ? 'Yes' : 'No',
'joined_at' => date('Y-m-d', strtotime($w->created_at)),
]; ];
}); });
$totalRevenue = DB::table('payments')->where('status', 'success')->sum('amount'); } elseif ($reportType === 'employers') {
$query = DB::table('users')
->leftJoin('employer_profiles', 'users.id', '=', 'employer_profiles.user_id')
->leftJoin('sponsors', 'users.email', '=', 'sponsors.email')
->where('users.role', 'employer')
->select(
'users.id',
'users.name',
'users.email',
'sponsors.status as sponsor_status',
'sponsors.subscription_status',
'users.created_at'
);
if ($startDate) {
$query->whereDate('users.created_at', '>=', $startDate);
}
if ($endDate) {
$query->whereDate('users.created_at', '<=', $endDate);
}
if ($status && $status !== 'all') {
$query->where('sponsors.subscription_status', $status);
}
if ($search) {
$query->where(function($q) use ($search) {
$q->where('users.name', 'like', "%{$search}%")
->orWhere('users.email', 'like', "%{$search}%");
});
}
$headers = ['Employer ID', 'Name', 'Email', 'Status', 'Subscription Status', 'Total Spent', 'Joined Date'];
$data = $query->orderBy('users.created_at', 'desc')->get()->map(function($emp) {
$totalPaid = DB::table('payments')->where('user_id', $emp->id)->where('status', 'success')->sum('amount');
return [
'id' => 'EMP-' . str_pad($emp->id, 4, '0', STR_PAD_LEFT),
'name' => $emp->name,
'email' => $emp->email,
'status' => ucfirst($emp->sponsor_status ?? 'Active'),
'subscription' => ucfirst($emp->subscription_status ?? 'Inactive'),
'total_spent' => number_format($totalPaid, 2) . ' AED',
'joined_at' => date('Y-m-d', strtotime($emp->created_at)),
];
});
} elseif ($reportType === 'payments') {
$query = DB::table('payments')
->leftJoin('users', 'payments.user_id', '=', 'users.id')
->select('payments.*', 'users.name as user_name', 'users.email as user_email');
if ($startDate) {
$query->whereDate('payments.created_at', '>=', $startDate);
}
if ($endDate) {
$query->whereDate('payments.created_at', '<=', $endDate);
}
if ($status && $status !== 'all') {
$query->where('payments.status', $status);
}
if ($search) {
$query->where(function($q) use ($search) {
$q->where('users.name', 'like', "%{$search}%")
->orWhere('payments.description', 'like', "%{$search}%");
});
}
$headers = ['Payment ID', 'Employer Name', 'Employer Email', 'Amount', 'Description', 'Status', 'Date'];
$data = $query->orderBy('payments.created_at', 'desc')->get()->map(function($pay) {
return [
'id' => 'PAY-' . str_pad($pay->id, 4, '0', STR_PAD_LEFT),
'employer_name' => $pay->user_name ?: 'System Guest / Sponsor',
'employer_email' => $pay->user_email ?: 'no-email@marketplace.com',
'amount' => number_format((float)$pay->amount, 2) . ' AED',
'description' => $pay->description ?: 'Subscription Plan',
'status' => ucfirst($pay->status),
'date' => date('Y-m-d H:i', strtotime($pay->created_at)),
];
});
} elseif ($reportType === 'tickets') {
$query = DB::table('support_tickets')
->leftJoin('users', 'support_tickets.user_id', '=', 'users.id')
->leftJoin('report_reasons', 'support_tickets.reason_id', '=', 'report_reasons.id')
->select('support_tickets.*', 'users.name as user_name', 'users.email as user_email', 'report_reasons.reason as reason_name');
if ($startDate) {
$query->whereDate('support_tickets.created_at', '>=', $startDate);
}
if ($endDate) {
$query->whereDate('support_tickets.created_at', '<=', $endDate);
}
if ($status && $status !== 'all') {
$query->where('support_tickets.status', $status);
}
if ($search) {
$query->where(function($q) use ($search) {
$q->where('users.name', 'like', "%{$search}%")
->orWhere('support_tickets.subject', 'like', "%{$search}%");
});
}
$headers = ['Ticket ID', 'User Name', 'User Email', 'Subject', 'Reason', 'Status', 'Voice Note Attached', 'Created Date'];
$data = $query->orderBy('support_tickets.created_at', 'desc')->get()->map(function($tick) {
return [
'id' => 'TCK-' . str_pad($tick->id, 4, '0', STR_PAD_LEFT),
'user_name' => $tick->user_name ?: 'System User',
'user_email' => $tick->user_email ?: 'N/A',
'subject' => $tick->subject,
'reason' => $tick->reason_name ?: 'General Support',
'status' => ucfirst($tick->status),
'has_voice_note' => $tick->voice_note_path ? 'Yes' : 'No',
'created_at' => date('Y-m-d H:i', strtotime($tick->created_at)),
];
});
} elseif ($reportType === 'safety') {
$query = DB::table('moderation_reports');
if ($startDate) {
$query->whereDate('reported_at', '>=', $startDate);
}
if ($endDate) {
$query->whereDate('reported_at', '<=', $endDate);
}
if ($status && $status !== 'all') {
$query->where('status', $status);
}
if ($search) {
$query->where(function($q) use ($search) {
$q->where('reported_user_name', 'like', "%{$search}%")
->orWhere('reported_by_name', 'like', "%{$search}%");
});
}
$headers = ['Report ID', 'Reported User', 'Reported By', 'Reason', 'Status', 'Reported At'];
$data = $query->orderBy('reported_at', 'desc')->get()->map(function($rep) {
return [
'id' => $rep->id,
'reported_user' => $rep->reported_user_name . ' (' . $rep->reported_user_role . ')',
'reported_by' => $rep->reported_by_name . ' (' . $rep->reported_by_role . ')',
'reason' => $rep->reason,
'status' => ucfirst($rep->status),
'reported_at' => date('Y-m-d H:i', strtotime($rep->reported_at)),
];
});
}
if ($export === 'csv') {
$filename = "report_" . $reportType . "_" . date('Ymd_His') . ".csv";
$callback = function() use ($data, $headers) {
$file = fopen('php://output', 'w');
// UTF-8 BOM
fprintf($file, chr(0xEF).chr(0xBB).chr(0xBF));
fputcsv($file, $headers);
foreach ($data as $row) {
fputcsv($file, array_values((array)$row));
}
fclose($file);
};
return response()->stream($callback, 200, [
"Content-type" => "text/csv; charset=UTF-8",
"Content-Disposition" => "attachment; filename={$filename}",
"Pragma" => "no-cache",
"Cache-Control" => "must-revalidate, post-check=0, pre-check=0",
"Expires" => "0"
]);
}
// Fetch distinct nationalities for filter
$nationalities = DB::table('workers')
->whereNotNull('nationality')
->where('nationality', '!=', '')
->distinct()
->pluck('nationality')
->toArray();
return Inertia::render('Admin/Analytics/Index', [ return Inertia::render('Admin/Analytics/Index', [
'worker_stats' => $workerStats, 'reportType' => $reportType,
'sponsor_stats' => $sponsorStats, 'startDate' => $startDate ?: '',
'dispute_stats' => $disputeStats, 'endDate' => $endDate ?: '',
'safety_stats' => $safetyStats, 'status' => $status,
'payments' => $payments, 'search' => $search ?: '',
'total_revenue' => (float)$totalRevenue, 'headers' => $headers,
'reportData' => $data->toArray(),
'nationalities' => $nationalities,
]); ]);
} }

View File

@ -101,7 +101,7 @@ public function getConversations(Request $request)
'worker_name' => $conv->worker->name ?? 'Candidate', 'worker_name' => $conv->worker->name ?? 'Candidate',
'nationality' => $conv->worker->nationality ?? 'Unknown', 'nationality' => $conv->worker->nationality ?? 'Unknown',
'worker_status' => strtolower($conv->worker->status ?? 'active'), 'worker_status' => strtolower($conv->worker->status ?? 'active'),
'salary' => ($conv->worker->salary ?? $conv->worker->expected_salary ?? 2000) . ' AED', 'salary' => ($conv->worker->salary ?? 2000) . ' AED',
'last_message' => $lastMsg->text ?? 'No messages yet.', 'last_message' => $lastMsg->text ?? 'No messages yet.',
'unread_count' => $unreadCount, 'unread_count' => $unreadCount,
'sent_at' => $lastMsg ? $lastMsg->created_at->diffForHumans() : 'Just now', 'sent_at' => $lastMsg ? $lastMsg->created_at->diffForHumans() : 'Just now',
@ -176,7 +176,7 @@ public function getMessages(Request $request, $id)
'worker_name' => $conv->worker->name ?? 'Candidate', 'worker_name' => $conv->worker->name ?? 'Candidate',
'nationality' => $conv->worker->nationality ?? 'Unknown', 'nationality' => $conv->worker->nationality ?? 'Unknown',
'worker_status' => strtolower($conv->worker->status ?? 'active'), 'worker_status' => strtolower($conv->worker->status ?? 'active'),
'salary' => ($conv->worker->salary ?? $conv->worker->expected_salary ?? 2000) . ' AED', 'salary' => ($conv->worker->salary ?? 2000) . ' AED',
]; ];
return response()->json([ return response()->json([

View File

@ -123,7 +123,7 @@ public function applicants($id)
'id' => $worker->id, 'id' => $worker->id,
'name' => $worker->name, 'name' => $worker->name,
'nationality' => $worker->nationality, 'nationality' => $worker->nationality,
'salary' => (int) $worker->expected_salary, 'salary' => (int) $worker->salary,
'experience' => ($worker->experience_years ?? 3) . ' Years', 'experience' => ($worker->experience_years ?? 3) . ' Years',
'status' => ucfirst($app->status), // Applied, Shortlisted, Hired, Rejected 'status' => ucfirst($app->status), // Applied, Shortlisted, Hired, Rejected
'match_score' => $worker->match_score ?? 90, 'match_score' => $worker->match_score ?? 90,

View File

@ -167,7 +167,7 @@ public function show($id)
'worker_name' => $activeConv->worker->name ?? 'Candidate', 'worker_name' => $activeConv->worker->name ?? 'Candidate',
'worker_status' => strtolower($activeConv->worker->status ?? 'active'), 'worker_status' => strtolower($activeConv->worker->status ?? 'active'),
'online' => true, 'online' => true,
'salary' => ($activeConv->worker->expected_salary ?? 2000) . ' AED', 'salary' => ($activeConv->worker->salary ?? 2000) . ' AED',
'nationality' => $activeConv->worker->nationality ?? 'Unknown', 'nationality' => $activeConv->worker->nationality ?? 'Unknown',
]; ];

View File

@ -1,281 +1,412 @@
import React, { useState } from 'react'; import React, { useState, useEffect } from 'react';
import { Head } from '@inertiajs/react'; import { Head, router } from '@inertiajs/react';
import AdminLayout from '@/Layouts/AdminLayout'; import AdminLayout from '@/Layouts/AdminLayout';
import { import {
FileText,
Users, Users,
Building2,
ShieldAlert, ShieldAlert,
CreditCard, DollarSign,
LifeBuoy,
Search, Search,
RefreshCw, RefreshCw,
Download, Download,
Building2, Calendar,
DollarSign Filter,
FileSpreadsheet,
Globe,
X,
FileText
} from 'lucide-react'; } from 'lucide-react';
export default function AnalyticsHub({ export default function ReportsHub({
worker_stats = { total: 0, verified: 0, active: 0, inactive: 0 }, reportType = 'workers',
sponsor_stats = { total: 0, verified: 0, active_subscribers: 0, unpaid: 0 }, startDate = '',
dispute_stats = { total: 0, open: 0, under_review: 0, resolved: 0 }, endDate = '',
safety_stats = { total: 0, pending: 0, resolved: 0 }, status = 'all',
payments = [], search = '',
total_revenue = 0 headers = [],
reportData = [],
nationalities = []
}) { }) {
const [searchTerm, setSearchTerm] = useState(''); // Local filter state
const [statusFilter, setStatusFilter] = useState('all'); const [localStartDate, setLocalStartDate] = useState(startDate);
const [localEndDate, setLocalEndDate] = useState(endDate);
const [localStatus, setLocalStatus] = useState(status);
const [localSearch, setLocalSearch] = useState(search);
const [localNationality, setLocalNationality] = useState('all');
// Filter payments dynamically // Sync with props when props change (e.g. after tab switch)
const filteredPayments = payments.filter(payment => { useEffect(() => {
const matchesSearch = setLocalStartDate(startDate);
payment.id.toString().toLowerCase().includes(searchTerm.toLowerCase()) || setLocalEndDate(endDate);
payment.user_name.toLowerCase().includes(searchTerm.toLowerCase()) || setLocalStatus(status);
payment.user_email.toLowerCase().includes(searchTerm.toLowerCase()) || setLocalSearch(search);
payment.description.toLowerCase().includes(searchTerm.toLowerCase()); }, [reportType, startDate, endDate, status, search]);
const matchesStatus = statusFilter === 'all' || payment.status.toLowerCase() === statusFilter.toLowerCase();
return matchesSearch && matchesStatus; const handleTabChange = (type) => {
}); router.get('/admin/analytics', {
report_type: type,
start_date: localStartDate,
end_date: localEndDate,
status: 'all',
search: ''
}, {
preserveState: false
});
};
const applyFilters = () => {
router.get('/admin/analytics', {
report_type: reportType,
start_date: localStartDate,
end_date: localEndDate,
status: localStatus,
search: localSearch,
nationality: reportType === 'workers' ? localNationality : undefined
}, {
preserveState: true,
replace: true
});
};
const resetFilters = () => {
setLocalStartDate('');
setLocalEndDate('');
setLocalStatus('all');
setLocalSearch('');
setLocalNationality('all');
router.get('/admin/analytics', {
report_type: reportType,
start_date: '',
end_date: '',
status: 'all',
search: ''
});
};
const handleExportCSV = () => { const handleExportCSV = () => {
const headers = ['Payment ID,Customer,Email,Details,Amount,Status,Date\n']; const params = new URLSearchParams({
const rows = filteredPayments.map(p => report_type: reportType,
`"${p.id}","${p.user_name}","${p.user_email}","${p.description}",${p.amount},"${p.status}","${p.created_at}"` start_date: localStartDate,
); end_date: localEndDate,
const blob = new Blob([headers.concat(rows.join('\n'))], { type: 'text/csv;charset=utf-8;' }); status: localStatus,
const link = document.createElement('a'); search: localSearch,
link.href = URL.createObjectURL(blob); export: 'csv'
link.setAttribute('download', `financial_report_${new Date().toISOString().split('T')[0]}.csv`); });
document.body.appendChild(link); if (reportType === 'workers' && localNationality !== 'all') {
link.click(); params.append('nationality', localNationality);
document.body.removeChild(link); }
window.location.href = `/admin/analytics?${params.toString()}`;
};
// Context-aware status list for dropdown
const getStatusOptions = () => {
switch (reportType) {
case 'workers':
return [
{ value: 'all', label: 'All Statuses' },
{ value: 'Available', label: 'Available' },
{ value: 'hired', label: 'Hired' },
{ value: 'inactive', label: 'Inactive' },
{ value: 'suspended', label: 'Suspended' },
{ value: 'banned', label: 'Banned' }
];
case 'employers':
return [
{ value: 'all', label: 'All Subscriptions' },
{ value: 'active', label: 'Active Pass' },
{ value: 'inactive', label: 'Inactive Pass' }
];
case 'payments':
return [
{ value: 'all', label: 'All Payments' },
{ value: 'success', label: 'Success' },
{ value: 'failed', label: 'Failed' },
{ value: 'refunded', label: 'Refunded' }
];
case 'tickets':
return [
{ value: 'all', label: 'All Statuses' },
{ value: 'Open', label: 'Open' },
{ value: 'In-Progress', label: 'In-Progress' },
{ value: 'Resolved', label: 'Resolved' },
{ value: 'Closed', label: 'Closed' }
];
case 'safety':
return [
{ value: 'all', label: 'All Statuses' },
{ value: 'Pending', label: 'Pending' },
{ value: 'Resolved', label: 'Resolved' }
];
default:
return [{ value: 'all', label: 'All Statuses' }];
}
};
const tabs = [
{ id: 'workers', label: 'Workers List', icon: Users, desc: 'Registration & status report' },
{ id: 'employers', label: 'Employers List', icon: Building2, desc: 'Subscription & spending report' },
{ id: 'payments', label: 'Payments Ledger', icon: DollarSign, desc: 'Revenue & transactions report' },
{ id: 'tickets', label: 'Support Inquiries', icon: LifeBuoy, desc: 'Inquiries & voice notes report' },
{ id: 'safety', label: 'Safety Moderation', icon: ShieldAlert, desc: 'Moderation & flagged user reports' },
];
const renderCell = (val, cellIdx, type) => {
const lowerVal = String(val).toLowerCase();
// Status Badges
if (['active', 'available', 'success', 'yes', 'approved', 'resolved'].includes(lowerVal)) {
return (
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider bg-emerald-50 text-emerald-700 border border-emerald-200">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-500"></span>
{val}
</span>
);
}
if (['pending', 'in-progress', 'under review', 'no'].includes(lowerVal)) {
return (
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider bg-amber-50 text-amber-700 border border-amber-200">
<span className="w-1.5 h-1.5 rounded-full bg-amber-500"></span>
{val}
</span>
);
}
if (['failed', 'inactive', 'suspended', 'banned', 'closed', 'rejected'].includes(lowerVal)) {
return (
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-wider bg-rose-50 text-rose-700 border border-rose-200">
<span className="w-1.5 h-1.5 rounded-full bg-rose-500"></span>
{val}
</span>
);
}
// ID Formats
if (typeof val === 'string' && (val.startsWith('WRK-') || val.startsWith('EMP-') || val.startsWith('PAY-') || val.startsWith('TCK-') || val.startsWith('REP-'))) {
return (
<span className="font-mono font-bold text-[#0F6E56] tracking-wider bg-teal-50 px-2.5 py-1 rounded-lg border border-teal-100">
{val}
</span>
);
}
// Email style
if (typeof val === 'string' && val.includes('@')) {
return <span className="font-medium text-gray-500 text-xs">{val}</span>;
}
return <span className="font-bold text-gray-800">{val}</span>;
}; };
return ( return (
<AdminLayout title="Reports"> <AdminLayout title="Operational Reports Portal">
<Head title="Reports" /> <Head title="Operational Reports Portal" />
<div className="font-sans max-w-7xl mx-auto space-y-8 pb-12"> <div className="max-w-7xl mx-auto space-y-6 pb-16 px-4 sm:px-6 lg:px-8">
{/* Simplified Header */} {/* Header Title & Description */}
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 border-b border-slate-200 pb-5"> <div className="bg-gradient-to-r from-teal-900 via-[#0F6E56] to-emerald-800 rounded-3xl p-6 md:p-8 text-white shadow-xl relative overflow-hidden">
<div> <div className="absolute right-0 bottom-0 opacity-10 pointer-events-none transform translate-y-6 translate-x-6">
<h1 className="text-xl font-black text-slate-800 uppercase tracking-tight">Platform Reports</h1> <FileText className="w-80 h-80" />
<p className="text-xs text-slate-500 mt-0.5 font-medium">Summary of workers, employers, disputes, and payments.</p>
</div> </div>
<div className="relative z-10 space-y-2">
<div className="flex items-center gap-2"> <span className="text-[10px] bg-teal-500/30 border border-teal-400/20 px-3 py-1.5 rounded-full font-bold uppercase tracking-widest text-teal-200">
<button Management Portal
onClick={() => window.location.reload()} </span>
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" <h1 className="text-2xl md:text-3xl font-black tracking-tight mt-1">Platform Reports Portal</h1>
> <p className="text-sm text-teal-100 max-w-2xl font-medium">
<RefreshCw className="w-3.5 h-3.5" /> Gain direct access to database operational reports, track registrations, monitor logs, analyze payment histories, and export filtered tabular datasets.
<span>Refresh</span> </p>
</button>
<button
onClick={handleExportCSV}
className="inline-flex items-center px-4 py-2 bg-[#0F6E56] hover:bg-[#085041] text-white rounded-xl text-xs font-black uppercase tracking-wider transition-all shadow-md gap-1.5"
>
<Download className="w-3.5 h-3.5" />
<span>Export CSV</span>
</button>
</div> </div>
</div> </div>
{/* Simplified Summary Cards */} {/* Report Module Navigation Tabs */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6"> <div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-5 gap-3">
{tabs.map((tab) => {
{/* Workers Card */} const Icon = tab.icon;
<div className="bg-white border border-slate-200 rounded-2xl p-5 shadow-sm space-y-4 hover:border-[#0F6E56]/30 transition-all"> const isActive = reportType === tab.id;
<div className="flex justify-between items-start"> return (
<div className="p-2.5 bg-teal-50 rounded-xl text-[#0F6E56]"> <button
<Users className="w-5 h-5" /> key={tab.id}
</div> onClick={() => handleTabChange(tab.id)}
<span className="text-[10px] bg-slate-105 text-[#0F6E56] font-extrabold uppercase px-2 py-0.5 rounded tracking-wide"> className={`flex flex-col items-start text-left p-4 rounded-2xl border transition-all duration-300 ${
Workers isActive
</span> ? 'bg-white border-[#0F6E56] shadow-lg scale-[1.01] ring-2 ring-[#0F6E56]/10'
</div> : 'bg-white text-gray-600 border-gray-200 hover:border-gray-300 hover:bg-gray-50/80 shadow-sm'
<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">
<div>
<span className="text-slate-400 uppercase tracking-widest text-[8px] block">Verified</span>
<span className="text-slate-700 font-extrabold text-xs">{worker_stats.verified}</span>
</div>
<div>
<span className="text-slate-400 uppercase tracking-widest text-[8px] block">Active</span>
<span className="text-slate-700 font-extrabold text-xs">{worker_stats.active}</span>
</div>
</div>
</div>
{/* Sponsors Card */}
<div className="bg-white border border-slate-200 rounded-2xl p-5 shadow-sm space-y-4 hover:border-[#0F6E56]/30 transition-all">
<div className="flex justify-between items-start">
<div className="p-2.5 bg-blue-50 rounded-xl text-blue-600">
<Building2 className="w-5 h-5" />
</div>
<span className="text-[10px] bg-blue-50 text-blue-700 font-extrabold uppercase px-2 py-0.5 rounded tracking-wide">
Employers
</span>
</div>
<div>
<span className="text-[10px] text-slate-400 font-bold uppercase tracking-wider block">Total Employers</span>
<span className="text-3xl font-black text-slate-800 block mt-1 tracking-tight">{sponsor_stats.total}</span>
</div>
<div className="pt-2 border-t border-slate-100 grid grid-cols-2 gap-2 text-[10px] font-bold text-slate-500">
<div>
<span className="text-slate-400 uppercase tracking-widest text-[8px] block">Active Plan</span>
<span className="text-slate-700 font-extrabold text-xs">{sponsor_stats.active_subscribers}</span>
</div>
<div>
<span className="text-slate-400 uppercase tracking-widest text-[8px] block">Unpaid</span>
<span className="text-slate-700 font-extrabold text-xs">{sponsor_stats.unpaid}</span>
</div>
</div>
</div>
{/* Disputes Card */}
<div className="bg-white border border-slate-200 rounded-2xl p-5 shadow-sm space-y-4 hover:border-[#0F6E56]/30 transition-all">
<div className="flex justify-between items-start">
<div className="p-2.5 bg-orange-50 rounded-xl text-orange-600">
<ShieldAlert className="w-5 h-5" />
</div>
<span className="text-[10px] bg-orange-50 text-orange-700 font-extrabold uppercase px-2 py-0.5 rounded tracking-wide">
Disputes
</span>
</div>
<div>
<span className="text-[10px] text-slate-400 font-bold uppercase tracking-wider block">Total Disputes</span>
<span className="text-3xl font-black text-slate-800 block mt-1 tracking-tight">{dispute_stats.open + dispute_stats.under_review}</span>
</div>
<div className="pt-2 border-t border-slate-100 grid grid-cols-2 gap-2 text-[10px] font-bold text-slate-500">
<div>
<span className="text-slate-400 uppercase tracking-widest text-[8px] block">Resolved</span>
<span className="text-slate-700 font-extrabold text-xs">{dispute_stats.resolved}</span>
</div>
<div>
<span className="text-slate-400 uppercase tracking-widest text-[8px] block">Pending Flags</span>
<span className="text-slate-700 font-extrabold text-xs">{safety_stats.pending}</span>
</div>
</div>
</div>
{/* Revenue Card */}
<div className="bg-white border border-slate-200 rounded-2xl p-5 shadow-sm space-y-4 hover:border-[#0F6E56]/30 transition-all">
<div className="flex justify-between items-start">
<div className="p-2.5 bg-emerald-50 rounded-xl text-emerald-600">
<DollarSign className="w-5 h-5" />
</div>
<span className="text-[10px] bg-emerald-50 text-emerald-700 font-extrabold uppercase px-2 py-0.5 rounded tracking-wide">
Revenue
</span>
</div>
<div>
<span className="text-[10px] text-slate-400 font-bold uppercase tracking-wider block">Total Revenue</span>
<span className="text-3xl font-black text-slate-800 block mt-1 tracking-tight">
AED {total_revenue.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
</span>
</div>
<div className="pt-2 border-t border-slate-100 grid grid-cols-2 gap-2 text-[10px] font-bold text-slate-500">
<div>
<span className="text-slate-400 uppercase tracking-widest text-[8px] block">Total Paid</span>
<span className="text-slate-700 font-extrabold text-xs">{payments.length}</span>
</div>
<div>
<span className="text-slate-400 uppercase tracking-widest text-[8px] block">Average Amount</span>
<span className="text-slate-700 font-extrabold text-xs">
AED {payments.length > 0 ? (total_revenue / payments.length).toFixed(0) : '0'}
</span>
</div>
</div>
</div>
</div>
{/* Simplified Payment Ledger */}
<div className="bg-white border border-slate-200 rounded-3xl p-6 shadow-sm space-y-6">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 border-b border-slate-100 pb-5">
<div>
<h3 className="text-sm font-black text-slate-800 uppercase tracking-widest flex items-center gap-2">
<CreditCard className="w-4.5 h-4.5 text-[#0F6E56]" />
<span>Payment List</span>
</h3>
<p className="text-[10px] text-slate-400 font-semibold mt-0.5">Search and check payments.</p>
</div>
{/* Search & Status Filters */}
<div className="flex flex-wrap items-center gap-3">
<div className="relative">
<Search className="w-4 h-4 text-slate-400 absolute left-3.5 top-1/2 -translate-y-1/2" />
<input
type="text"
placeholder="Search payments..."
className="bg-slate-50 border border-slate-200 rounded-xl pl-9 pr-4 py-2 text-xs font-semibold focus:bg-white outline-none focus:ring-2 focus:ring-[#0F6E56]/10 w-64 shadow-sm"
value={searchTerm}
onChange={e => setSearchTerm(e.target.value)}
/>
</div>
<select
className="bg-slate-50 border border-slate-200 rounded-xl px-4 py-2 text-xs font-semibold outline-none cursor-pointer focus:bg-white focus:ring-2 focus:ring-[#0F6E56]/10 shadow-sm"
value={statusFilter}
onChange={e => setStatusFilter(e.target.value)}
> >
<option value="all">All Payments</option> <div className="flex items-center gap-2.5">
<option value="success">Success</option> <div className={`p-2 rounded-xl ${isActive ? 'bg-teal-50 text-[#0F6E56]' : 'bg-gray-100 text-gray-500'}`}>
<option value="pending">Pending</option> <Icon className="w-5 h-5" />
<option value="failed">Failed</option> </div>
</select> <span className={`text-xs font-black uppercase tracking-wider ${isActive ? 'text-gray-900' : 'text-gray-700'}`}>
{tab.label}
</span>
</div>
<span className="text-[10px] text-gray-400 font-semibold mt-2 line-clamp-1">
{tab.desc}
</span>
</button>
);
})}
</div>
{/* Filter & Table Block */}
<div className="bg-white border border-gray-200 rounded-3xl shadow-sm overflow-hidden">
{/* Filter Bar */}
<div className="p-6 border-b border-gray-100 bg-gray-50/50 space-y-4">
<div className="flex items-center gap-2 text-xs font-bold text-gray-800 uppercase tracking-wider">
<Filter className="w-4 h-4 text-teal-600" />
<span>Filters & Query Parameters</span>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
{/* Start Date */}
<div className="space-y-1">
<label className="text-[10px] text-gray-400 font-extrabold uppercase tracking-widest block">Start Date</label>
<div className="relative">
<Calendar className="w-3.5 h-3.5 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" />
<input
type="date"
className="w-full bg-white border border-gray-250 rounded-xl pl-9 pr-3 py-2 text-xs font-bold focus:ring-2 focus:ring-[#0F6E56]/10 outline-none transition-all"
value={localStartDate}
onChange={e => setLocalStartDate(e.target.value)}
/>
</div>
</div>
{/* End Date */}
<div className="space-y-1">
<label className="text-[10px] text-gray-400 font-extrabold uppercase tracking-widest block">End Date</label>
<div className="relative">
<Calendar className="w-3.5 h-3.5 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" />
<input
type="date"
className="w-full bg-white border border-gray-250 rounded-xl pl-9 pr-3 py-2 text-xs font-bold focus:ring-2 focus:ring-[#0F6E56]/10 outline-none transition-all"
value={localEndDate}
onChange={e => setLocalEndDate(e.target.value)}
/>
</div>
</div>
{/* Status filter */}
<div className="space-y-1">
<label className="text-[10px] text-gray-400 font-extrabold uppercase tracking-widest block">Status</label>
<select
className="w-full bg-white border border-gray-250 rounded-xl px-3 py-2 text-xs font-bold focus:ring-2 focus:ring-[#0F6E56]/10 outline-none cursor-pointer transition-all"
value={localStatus}
onChange={e => setLocalStatus(e.target.value)}
>
{getStatusOptions().map(opt => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
{/* Workers Tab specific filter: Nationality */}
{reportType === 'workers' ? (
<div className="space-y-1">
<label className="text-[10px] text-gray-400 font-extrabold uppercase tracking-widest block">Nationality</label>
<div className="relative">
<Globe className="w-3.5 h-3.5 text-gray-400 absolute left-3 top-1/2 -translate-y-1/2" />
<select
className="w-full bg-white border border-gray-250 rounded-xl pl-9 pr-3 py-2 text-xs font-bold focus:ring-2 focus:ring-[#0F6E56]/10 outline-none cursor-pointer transition-all"
value={localNationality}
onChange={e => setLocalNationality(e.target.value)}
>
<option value="all">All Nationalities</option>
{nationalities.map(n => (
<option key={n} value={n}>{n}</option>
))}
</select>
</div>
</div>
) : (
<div className="hidden lg:block"></div>
)}
{/* Search Box */}
<div className="space-y-1">
<label className="text-[10px] text-gray-400 font-extrabold uppercase tracking-widest block">Search Query</label>
<div className="relative">
<Search className="w-3.5 h-3.5 text-gray-400 absolute left-3.5 top-1/2 -translate-y-1/2" />
<input
type="text"
placeholder="Search by keywords..."
className="w-full bg-white border border-gray-250 rounded-xl pl-9 pr-3 py-2 text-xs font-bold focus:ring-2 focus:ring-[#0F6E56]/10 outline-none transition-all placeholder-gray-400"
value={localSearch}
onChange={e => setLocalSearch(e.target.value)}
/>
</div>
</div>
</div>
{/* Action buttons */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 pt-2">
<span className="text-[11px] text-gray-400 font-bold uppercase tracking-wider bg-gray-100 px-3 py-1.5 rounded-lg border border-gray-200/50 self-start">
Matches found: {reportData.length} records
</span>
<div className="flex flex-wrap items-center gap-2">
<button
onClick={resetFilters}
className="inline-flex items-center gap-1.5 px-4 py-2 border border-gray-200 bg-white hover:bg-gray-50 text-gray-600 rounded-xl text-xs font-bold uppercase tracking-wider transition-colors"
>
<X className="w-3.5 h-3.5" />
<span>Reset</span>
</button>
<button
onClick={applyFilters}
className="inline-flex items-center gap-1.5 px-5 py-2 bg-[#0F6E56] hover:bg-[#0c5845] text-white rounded-xl text-xs font-bold uppercase tracking-wider transition-colors shadow-sm shadow-teal-900/10"
>
<RefreshCw className="w-3.5 h-3.5" />
<span>Apply Filters</span>
</button>
<button
onClick={handleExportCSV}
className="inline-flex items-center gap-1.5 px-5 py-2 bg-emerald-600 hover:bg-emerald-700 text-white rounded-xl text-xs font-bold uppercase tracking-wider transition-colors shadow-sm shadow-emerald-900/10"
>
<Download className="w-3.5 h-3.5" />
<span>Export CSV</span>
</button>
</div>
</div> </div>
</div> </div>
{/* Payments Table */} {/* Report Table View */}
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full text-left border-collapse"> <table className="w-full border-collapse text-left">
<thead> <thead>
<tr className="border-b border-slate-150 text-[10px] text-slate-400 font-black uppercase tracking-widest"> <tr className="border-b border-gray-200 bg-gray-50 text-[10px] text-gray-400 font-black uppercase tracking-widest">
<th className="px-5 py-3">Payment ID</th> {headers.map((hdr, idx) => (
<th className="px-5 py-3">Customer</th> <th key={idx} className="px-6 py-4">{hdr}</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> </tr>
</thead> </thead>
<tbody className="divide-y divide-slate-100 text-xs font-bold text-slate-700"> <tbody className="divide-y divide-gray-100">
{filteredPayments.map(p => ( {reportData.map((row, idx) => (
<tr key={p.id} className="hover:bg-slate-50/50 transition-colors"> <tr key={idx} className="hover:bg-teal-50/10 transition-colors">
<td className="px-5 py-4 font-mono text-[#0F6E56]">TXN-{p.id}</td> {Object.values(row).map((val, cellIdx) => (
<td className="px-5 py-4"> <td key={cellIdx} className="px-6 py-4 text-xs">
<div> {renderCell(val, cellIdx, reportType)}
<span className="font-extrabold text-slate-800 block">{p.user_name}</span> </td>
<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> </tr>
))} ))}
{filteredPayments.length === 0 && ( {reportData.length === 0 && (
<tr> <tr>
<td colSpan="6" className="p-12 text-center text-slate-400 font-bold uppercase"> <td colSpan={headers.length || 1} className="px-6 py-16 text-center">
No payments found. <div className="flex flex-col items-center justify-center space-y-2 text-gray-400">
<FileSpreadsheet className="w-12 h-12 stroke-[1.5]" />
<span className="font-black uppercase tracking-wider text-xs">No Records Found</span>
<span className="text-[10px] text-gray-400 font-semibold max-w-xs leading-relaxed">
There are no database records matching your current filter configuration.
</span>
</div>
</td> </td>
</tr> </tr>
)} )}
@ -283,7 +414,6 @@ export default function AnalyticsHub({
</table> </table>
</div> </div>
</div> </div>
</div> </div>
</AdminLayout> </AdminLayout>
); );

View File

@ -252,13 +252,13 @@
Route::get('/workers', [\App\Http\Controllers\Employer\WorkerController::class, 'index'])->name('employer.workers'); Route::get('/workers', [\App\Http\Controllers\Employer\WorkerController::class, 'index'])->name('employer.workers');
Route::get('/workers/{id}', [\App\Http\Controllers\Employer\WorkerController::class, 'show'])->name('employer.workers.show'); Route::get('/workers/{id}', [\App\Http\Controllers\Employer\WorkerController::class, 'show'])->name('employer.workers.show');
Route::get('/workers/{id}/hire', function ($id) { Route::get('/workers/{id}/hire', function ($id) {
$worker = \App\Models\Worker::with('category')->findOrFail($id); $worker = \App\Models\Worker::findOrFail($id);
$workerData = [ $workerData = [
'id' => $worker->id, 'id' => $worker->id,
'name' => $worker->name, 'name' => $worker->name,
'nationality' => $worker->nationality, 'nationality' => $worker->nationality,
'category' => $worker->category->name ?? 'General Worker', 'category' => $worker->preferred_job_type ?? 'Domestic Worker',
'salary' => (int) $worker->expected_salary, 'salary' => (int) $worker->salary,
]; ];
return Inertia::render('Employer/Hiring/Confirm', ['worker' => $workerData]); return Inertia::render('Employer/Hiring/Confirm', ['worker' => $workerData]);
})->name('employer.hiring.confirm'); })->name('employer.hiring.confirm');